Virtualization · Sandboxing

Why a Seccomp Filter Cannot Inspect Syscall Arguments in Memory

The pointer-versus-contents problem in syscall filtering, the race that makes dereferencing unsafe even if it were allowed, and the mechanisms that recover argument-aware policy without it.

The most common disappointment with seccomp arrives at the first attempt to filter open by filename. The filter sees the syscall number, the six register-width arguments, and nothing else. Argument two of openat is a userspace address; the pathname lives at that address, in memory the filter has no way to read. The natural reaction is to ask why the kernel does not simply hand the filter the string. The answer is a race condition, and understanding it explains why path-based and argument-based policy had to be built as separate mechanisms rather than as a richer filter program.

The record holds addresses, not values

A filter loads args[1] and gets a 64-bit number. On a typical 64-bit process that number is somewhere in the process’s own address space, but the filter cannot distinguish a pointer to a pathname from a length, a file descriptor, or a constant that happens to fall in the same numeric range. Filters that compare pointer arguments against bounds are therefore comparing numbers with no semantic anchor: a policy that requires args[1] to lie within a specific mapping is checking a property the kernel will not check again, and the value can be anything by the time the kernel reads it.

The one class of argument that filtering handles well is the scalar: file descriptors, flags, request codes, lengths, and small enums. A filter can require that openat’s flags contain O_RDONLY and not O_CREAT, that ioctl’s request number match an allowlist, that mmap’s protection lacks PROT_EXEC. These are comparisons on integers the kernel will use as-is.

Dereferencing would be a time-of-check to time-of-use race

Suppose the kernel were to copy the string out of the caller’s memory and present it to the filter. Between the filter’s verdict and the syscall’s own copy_from_user of the same address, another thread of the same process, or a signal handler, or the caller itself through a shared mapping, can rewrite the buffer. The filter approves /etc/passwd, the second read returns /etc/shadow, and the syscall proceeds. The window is not theoretical: it is the ordinary behaviour of any multithreaded program, and it is the same race that has repeatedly defeated naive path checks in setuid code.

Making the race go away requires the kernel to pin the memory, which means either copying the argument to a kernel buffer once and passing that buffer to the syscall body instead of re-reading userspace, or refusing to allow the memory to change. The former is a substantial restructuring of every syscall entry path, and it changes semantics: a syscall that currently re-reads a buffer after a partial transfer would have to stop doing so. The latter is not possible for writable user memory.

The kernel’s position is therefore that a filter sees the register state at entry and nothing else, and that any policy needing contents must be enforced at a point where the kernel already has the contents in kernel memory. openat2 with RESOLVE_* flags is exactly such a point.

Resolve flags move path policy into the kernel

openat2 takes a struct open_how with a resolve field. Setting RESOLVE_BENEATH confines resolution to the directory named by the dirfd; RESOLVE_NO_SYMLINKS refuses any symlink in the path; RESOLVE_NO_MAGICLINKS refuses /proc/self/fd-style links; RESOLVE_IN_ROOT treats the dirfd as a root for absolute paths. These are enforced during path walk, after each component is read, so there is no window in which the checked string and the used string differ.

A seccomp filter cannot set those flags, but it can require them. A filter can inspect args[2], which for openat2 points to the open_how structure, and this is the one place where pointer inspection appears to work, because the filter cannot read it either. What the filter can do is refuse openat2 entirely and refuse openat as well, forcing callers through a wrapper that the sandbox itself provides, inside which the flags are set by code the sandbox controls. That is a policy about which code may open files, not about which files may be opened, and it is a weaker guarantee that depends on the wrapper not being bypassable.

Landlock enforces the path policy the filter cannot express

Landlock is a separate mechanism with a separate domain, described in full in Landlock Rulesets: Path Hierarchies and Access Masks. A process builds a ruleset of (handled_access_fs, rules) pairs, adds a rule binding a file hierarchy to a set of permitted access rights, and restricts itself with landlock_restrict_self. From that point, opening a file outside the permitted hierarchies fails with EACCES, and the check happens in the kernel’s path walk with the path already resolved.

The access rights are a bitmask of specific operations: LANDLOCK_ACCESS_FS_EXECUTE, WRITE_FILE, READ_FILE, REMOVE_DIR, REMOVE_FILE, MAKE_CHAR, MAKE_DIR, MAKE_REG, MAKE_SOCK, MAKE_FIFO, MAKE_BLOCK, MAKE_SYM, REFER, and TRUNCATE. Because these are per-hierarchy rather than per-syscall, a single rule can express “this directory is writable but not executable” without enumerating the syscalls that could achieve a write. That is a categorically different shape of policy from an allowlist of numbers, and it is the shape most sandboxes actually want.

Landlock composes with seccomp by intersection, in the same way stacked filters compose with each other: a Landlock ruleset cannot grant a capability that a seccomp filter denies, and vice versa. The layering is deliberate, because each covers the other’s blind spot. Landlock has no visibility into ioctl or network sockets in its filesystem ruleset, and seccomp has no visibility into paths. Neither is sufficient alone.

The restriction is also irreversible and inherited: landlock_restrict_self cannot be undone, and children inherit the ruleset. The handled_access_fs mask must be a subset of what the running kernel supports, discovered through landlock_create_ruleset with LANDLOCK_CREATE_RULESET_VERSION, so a ruleset built against a newer kernel’s access bits fails on an older one rather than silently dropping the bits it does not understand.

The supervisor reads memory, but pays for it

The remaining option for genuine argument inspection is to move the decision out of the kernel. SECCOMP_RET_USER_NOTIF suspends the calling thread and delivers a notification to a supervising process holding the filter’s file descriptor. The supervisor runs as an ordinary process, so it can read the caller’s memory with process_vm_readv, resolve the path itself, and answer.

Three properties make this expensive and delicate. First, every intercepted syscall becomes two context switches plus a supervisor-side decision, so the mechanism is used on a narrow set of calls rather than on all of them. Second, the memory read is subject to the same race: the supervisor’s copy can be stale by the time the syscall proceeds. The kernel provides SECCOMP_IOCTL_NOTIF_ADDFD so that a supervisor can inject a file descriptor it opened itself, which sidesteps the race by making the syscall’s effect the supervisor’s own action rather than the caller’s; that is the sound pattern, and it works only for syscalls that consume a descriptor. Third, the supervisor must be outside the sandbox’s thread group, or its own syscalls are intercepted and it deadlocks against itself.

Where each mechanism belongs

The division of labour that follows is not a matter of taste. Scalar argument policy, syscall presence, and architecture belong in seccomp, because those are the fields the filter sees. Path and file-hierarchy policy belongs in Landlock or in openat2 resolve flags, because those are enforced after resolution. Anything requiring the contents of a structure, whether an ioctl payload, a setsockopt option value, or a mount data string, belongs either in a supervisor or in a decision not to expose the operation at all. A sandbox that tries to enforce content policy through seccomp comparison of pointer values is enforcing a property of numbers that the kernel does not honour, and the gap between the number it checked and the bytes it used is the whole attack surface.

References

  1. seccomp(2) — Linux manual page — man-pages project
  2. openat2(2) — Linux manual page — man-pages project
  3. Linux kernel documentation — Landlock — Linux kernel project