LSM BPF as a live kernel patching tool

The Linux Security Module (LSM) framework has long offered a hook-based way to implement Mandatory Access Control in the kernel, but practical choices were limited: configure an existing module like AppArmor or SELinux, or write a custom kernel module. Linux 5.7 added a third path in the form of LSM extended Berkeley Packet Filters (LSM BPF), which lets developers express granular security policy in C without kernel configuration or module loading. eBPF programs are verified at load time and run whenever a relevant LSM hook is hit along a call path.

The problem: user namespaces as an attack surface

Linux namespaces provide resource isolation for containers and sandboxes, and most are fairly innocuous. The USER namespace stands apart: inside it, the namespace owner effectively has root privileges over that namespace's resources. This design enables rootless containers and similar tooling, but it also exposes an attack surface. An unprivileged user can invoke the unshare syscall with the CLONE_NEWUSER flag, create a new user namespace mapped to the caller's original namespace, and potentially escalate privileges. The same risk exists with the clone and clone3 syscalls, but this effort focuses on unshare.

Past attempts to mainline a sysctl that would disable unprivileged CLONE_NEWUSER met with pushback, notably because the resulting knob could not grant or deny the feature selectively for specific applications. Debian carries its own out-of-tree patch, but the vanilla kernel went without such control.

Approach: intercept with eBPF

The alternative is LSM BPF, which avoids kernel modifications entirely. It also makes sense for the denial logic to live in a user-space-loadable BPF program that can express more complex rules than a simple yes/no toggle. The first task is figuring out which hook to attach to.

Finding the right hook

Tracing via include/linux/syscalls.h in the v5.18 tree for the unshare syscall definition leads to kernel/fork.c, where ksys_unshare() is implemented and in turn calls unshare_userns(). Looking into that function, a call to prepare_creds() appears right before create_user_ns() — the operation to block. Task-based LSM hooks are listed in include/linux/lsm_hooks.h, and the cred_prepare hook matches the use of prepare_creds(). An inspection of security_prepare_creds() in security/security.c confirms the hook is invoked at that point in the flow, making cred_prepare a reliable place to deny the namespace creation.

Note that the hook fires early enough that a denial aborts the syscall before namespace setup begins, which matters for the performance side of things.

Writing the policy

The sample program is built with the eBPF compile-once-run-everywhere (CO-RE) approach. It targets the x86_64 architecture; LSM BPF on ARM64 is still in development. The test environment runs kernel 5.15 or newer with the appropriate LSM eBPF configuration; if CONFIG_LSM does not already include "bpf", a lsm=bpf boot option may be required.

The BPF source, deny_unshare.bpf.c, declares minimal versions of the kernel structures it touches — just enough fields for the program to function. CO-RE performs the rest of the relocations against the target kernel, which keeps program definitions short and portable.

The core section of the program is a BPF function annotated for the cred_prepare hook. It inspects whether the newly prepared credentials correspond to a new user namespace creation by testing the clone flags for CLONE_NEWUSER. When that flag is present, the program checks whether the caller has sufficient privilege (usually by inspecting the user namespace of the current credentials) and, if not, returns an error code to block the syscall.

Loading and attaching use native libbpf. The user-space loader deny_unshare.c opens the BPF object file, loads the program into the kernel, and attaches it to the cred_prepare hook via the BPF link. A minimal Makefile compiles the BPF object and the loader with clang and links the latter against libbpf.

Once loaded, an unprivileged attempt to run unshare with a user namespace mapping fails. The earlier call runs without issue; the later attempt is refused. The policy can also implement an allow-list: privileged callers (such as those running as root in the initial user namespace) pass through without the extra check, which keeps legitimate operations, like Docker-style container setup, functioning.

Performance cost

The blocking policy adds a small amount of work to path that includes the cred_prepare hook. Measuring with the one-line unshare command mapped into a namespace shows the effect at the syscall level using ftrace's CPU cycle resolution. The timestamps for syscall enter/exit are enabled for unshare specifically, and the ftrace clock is set to count cycles.

In the test run, the policy-free execution used 63,294 cycles and the policy-enabled execution used 70,138 cycles — a delta of 6,844 cycles, roughly 10%. That result is for a single invocation. Given that unshare typically runs at process creation time rather than in a hot loop, the overhead is reasonable for the security benefit obtained. Still, the cost scales with call frequency, so programs exercising the hook in a hot path deserve their own measurements.

One caveat surfaced during development: the error surfaced to the user from a denied cred_prepare hook is "Cannot allocate memory", which is unhelpful when the actual cause is a denial. A patch has been proposed to propagate better error codes up the call stack, but the conclusion drawn from that work is that a dedicated hook may be a better fit. That investigation is ongoing.