Before you start#
Review system-call entry, credentials, and LSM dispatch. The pinned source baseline is upstream v6.12; an actual host can have a different kernel, configuration, container filter or security policy. The optional seccomp program runs only in its own short-lived process. Never disable host security to make a test succeed.
Learning goals#
Explain seccomp filter installation and action precedence, distinguish a syscall argument value from pointed-to memory, reason about Landlock handled rights and layers, and interpret audit records without treating logging as authorization.
Source walkthrough#
- Seccomp
- Per-thread syscall metadata and cBPF action
- Landlock
- Handled object rights and restrictive inherited layers
- Audit
- Configured event records; not an authorization grant
- Other checks
- Credentials, VFS and other LSMs still participate
Seccomp restricts system calls made by a thread. Strict mode has a small fixed syscall allowance; filter mode evaluates a classic BPF program over struct seccomp_data, not an arbitrary general eBPF program. That structure supplies the syscall number, audit architecture, instruction pointer and raw argument values. A filter can compare a pointer's numeric value but cannot dereference it to read a pathname or verify the contents it points to. Architecture validation is essential: syscall numbers can mean different things for different ABIs, and x86-64 systems can also have compatibility ABIs. An architecture check alone is not a complete x32 policy; an actual production allowlist must account for every enabled ABI.
A filter returns an action plus optional data. Actions include kill, trap, return an errno, user notification, trace, log and allow, subject to kernel support. When filters are stacked, the kernel selects the highest-precedence action, with the data handling rules specified by the UAPI; a later permissive filter cannot override a stronger earlier denial. A filter installed on one thread is inherited by children and across exec as documented, provided those operations are allowed. It is not automatically applied to pre-existing sibling threads unless synchronization is requested and succeeds.
The stacking rule is in seccomp_run_filters in kernel/seccomp.c, v6.12, SPDX GPL-2.0 (the declarations and the cache shortcut above the loop are omitted):
for (; f; f = f->prev) {
u32 cur_ret = bpf_prog_run_pin_on_cpu(f->prog, sd);
if (ACTION_ONLY(cur_ret) < ACTION_ONLY(ret)) {
ret = cur_ret;
*match = f;
}
}
return ret;
The loop keeps the filter whose ACTION_ONLY value is lowest. That value is a signed cast of the action bits, so a kill action with its high bit set compares lowest and wins over ALLOW. The comment says the data field is ignored for this choice. The excerpt does not show how the chosen action is carried out.
Installation and failure
An unprivileged thread normally sets no_new_privs before installing a seccomp filter, unless it has the required CAP_SYS_ADMIN in its user namespace. The irreversible no-new-privileges property prevents a filter from being used to trick a subsequently privileged exec into gaining authority under a manipulated syscall view. It does not remove credentials or permissions already held. Filter validation checks the cBPF program and rejects invalid programs; allocation, copying from userspace, existing seccomp state and security policy can also fail.
In kernel/seccomp.c, seccomp_set_mode_filter validates flags before preparing and installing a filter. The following exact v6.12 SPDX GPL-2.0 excerpt omits surrounding comments and branches:
if ((flags & SECCOMP_FILTER_FLAG_TSYNC) &&
(flags & SECCOMP_FILTER_FLAG_NEW_LISTENER) &&
((flags & SECCOMP_FILTER_FLAG_TSYNC_ESRCH) == 0))
return -EINVAL;
TSYNC requests synchronization of the filter across a thread group; NEW_LISTENER requests a user-notification listener fd. This condition rejects their combination unless the specified TSYNC error-reporting flag is present, because otherwise success's listener fd and a TSYNC failure's thread identifier could be ambiguous. The source then prepares the filter; failed copying or verification returns an error. Listener fd allocation and listener initialization have their own failure cleanup. Synchronization can fail if a sibling cannot adopt the filter: the caller must interpret the return according to the requested flags rather than assume all threads are restricted.
A syscall, a pointer, and a notification race#
Imagine an x86-64 process installs a filter that verifies AUDIT_ARCH_X86_64, returns EPERM for getpid, and allows other calls. A direct syscall(SYS_getpid) receives -1 with errno EPERM; another syscall allowed by both this filter and any earlier filters can proceed. The program below tests that exact direct syscall in a new process. A library wrapper or cached value can conceal the syscall, so the test avoids relying on a wrapper's implementation. A filter rejecting a syscall number does not prove that no other syscall or existing fd can achieve a similar effect.
Select a field to connect its position, bytes, and meaning.
24 bytes in file order. Offsets start at zero.
Selected: nr. int syscall number; 0x27 is 39, getpid on x86-64.
Read the byte table
| Offset | Field | Hex bytes | Interpretation |
|---|---|---|---|
| 0 | nr | 27 00 00 00 | int syscall number; 0x27 is 39, getpid on x86-64 |
| 4 | arch | 3e 00 00 c0 | AUDIT_ARCH_X86_64, little-endian; check it before the number |
| 8 | instruction_pointer | 00 00 00 00 00 00 00 00 | Caller instruction pointer; zeros are a placeholder value |
| 16 | args[0] | 00 00 00 00 00 00 00 00 | First raw argument as a number; a pointer value does not show its target |
Suppose instead a broker receives a seccomp user notification for an openat request. The notification includes the target's syscall data and an identifier; a listener can inspect the request and reply or perform a supported action. The pathname argument is a pointer into the target's address space. A separate target thread can change the pointed-to bytes between inspection and use. Validating the notification identifier and managing cancellation are necessary, but do not freeze arbitrary target memory or make a continue response a safe pathname authorization primitive. The target can exit, receive a signal or change state before the listener replies. Treat stale notification IDs and failed responses as normal races, and design the broker so the authorization decision applies to the actual object operation, not only a previously copied string.
- Target threadKernel seccomp
openat with a pathname pointer
- Kernel seccompBroker listener
USER_NOTIF: syscall data and notification ID
- Broker listenerTarget thread
read the pathname from target memory
- Broker listenerKernel seccomp
Return. reply with the matching ID
- Kernel seccompTarget thread
Return. continue or fail the syscall
Time runs downward. Dashed arrows show returns.
Read the text version
- Target thread → Kernel seccomp: openat with a pathname pointer
- Kernel seccomp → Broker listener: USER_NOTIF: syscall data and notification ID
- Broker listener → Target thread: read the pathname from target memory
- Broker listener → Kernel seccomp (return): reply with the matching ID
- Kernel seccomp → Target thread (return): continue or fail the syscall
SECCOMP_RET_USER_NOTIF is distinct from an LSM policy. A listener is a file descriptor with lifetime and access control of its own. If no suitable listener exists, or a listener dies, the resulting behavior follows seccomp's documented rules rather than automatically allowing the syscall. The seccomp filter documentation explains return actions, the data structure and limitations, including that precedence compares action bits and equal-precedence filters take data from the most recently installed filter. A filter is one sandbox layer; it is not a replacement for file access controls, namespace design, safe fd handling or application-level validation.
Landlock: handled rights, rules and layers#
Landlock is an LSM that lets a process add supported restrictions to itself and future children. A ruleset declares handled access rights: those are the rights the layer will restrict according to its rules. A filesystem rule associates allowed rights with a referenced path hierarchy; an unhandled right is not automatically forbidden by that ruleset. The ruleset fd is an object used to build the policy, not the sandbox itself. After adding rules, landlock_restrict_self applies a layer to the calling thread under its prerequisites. Layers stack restrictively, so later layers do not remove restrictions imposed by earlier ones.
The exact v6.12 source in security/landlock/syscalls.c, GPL-2.0-only, contains this excerpt from landlock_create_ruleset; surrounding argument validation is omitted:
if (!is_initialized())
return -EOPNOTSUPP;
if (flags) {
if ((flags == LANDLOCK_CREATE_RULESET_VERSION) && !attr &&
!size)
return LANDLOCK_ABI_VERSION;
The initialization check first distinguishes an unavailable Landlock instance from a supported query. The version query requires the exact version flag, null attributes and zero size; other invalid flag combinations reach an error branch. In this upstream v6.12 source, LANDLOCK_ABI_VERSION is 6. A running system can report another version, fail with ENOSYS because the syscall is absent, return EOPNOTSUPP because Landlock is unavailable, or be blocked by an outer policy. Probe rather than assuming the host matches the teaching tag.
A concrete synthetic ruleset handles filesystem read and write, and grants read under a directory but no write there. A task restricted by that layer can read an eligible file under that hierarchy if all other checks allow it; a write covered by the handled right is denied unless another applicable Landlock rule grants it within the layer. An unrelated right not declared handled is outside that layer's restrictions, though DAC or another LSM can still deny it. A second Landlock layer allowing write cannot erase the first layer's denial. Actual rules depend on ABI version, object type, allowed masks, file descriptors and the kernel's path semantics.
- Handled and granted
- A rule in this layer allows the right under the referenced hierarchy
- Handled, no grant
- This layer denies the right; a later layer cannot erase that denial
- Not handled
- This layer does not restrict the right; DAC and other LSMs still apply
Landlock rules use file descriptors identifying objects and are checked at particular VFS operations. Some access rights are determined at open time and retained on the resulting file descriptor, including when passed to another process; restrictions do not simply revoke every capability of already-open descriptors. Renames, hard links and reparenting have special semantics and constraints. A directory path string alone does not capture the full lifetime and hierarchy relationship. Rights such as REFER, TRUNCATE, device ioctl and TCP bind/connect were introduced at different ABI levels; version 6 adds scoped controls including abstract Unix-socket and signal scoping where supported. Network rules in this generation cover specific TCP port operations, not all protocols, DNS, routing or arbitrary packet content. Consult the v6.12 Landlock documentation and negotiate the running ABI before constructing a policy.
Audit records are evidence, not an access grant#
The audit subsystem generates records for configured events and communicates with userspace over NETLINK_AUDIT. Rules and userspace collection determine what is recorded; audit itself is not a general authorization engine for every action. Multiple records can describe one event and share an audit serial and timestamp. A useful investigation preserves the sequence and relevant subject, object, syscall and result fields rather than reading a single line out of context. Event ordering across CPUs and the relationship between wall-clock time and sequence should be interpreted carefully.
Backlog pressure, rate limits, daemon availability, configuration and loss counters affect completeness. A missing audit record does not prove that no operation occurred; a record showing an attempted operation does not alone prove the attempt succeeded. Audit may contain sensitive pathnames and identities, so treat collection and sharing accordingly. The kernel audit implementation is a starting point for the actual queue and netlink behavior; do not change global audit rules in this lesson. Audit records should be read with their event type and shared serial in view; syscall, path and result records may be separate records for one event.
What the filter cannot reconstruct#
A syscall filter sees the six numeric syscall arguments at the moment of entry, not a stable description of all kernel objects those numbers reference. An fd number can later be closed and reused for another object; comparing a number is meaningful only under a carefully controlled descriptor lifecycle. A pointer to a structure can refer to memory another thread mutates, and a syscall may use nested pointers. Even a correct filter cannot infer the ultimate filesystem object by reading only the address value. Restricting dangerous operations, constraining file descriptors and using object-aware controls are complementary design steps.
An action such as ERRNO prevents the selected syscall from executing and returns the encoded error to the caller; it does not undo work completed by previous calls. A kill action has different consequences from a recoverable error, and the choice affects cleanup and application behavior. Multiple filters are evaluated according to action precedence, so a filter that allows a call cannot weaken an earlier filter returning a stronger action. In a multithreaded program, installing only on the calling thread leaves already-existing siblings unchanged unless the thread synchronization operation succeeds; a failed synchronization must be handled before asserting that the whole group is filtered.
Landlock rules are also an incremental contract rather than a complete inventory of all possible rights. For example, if an application handles only file read but later needs to prohibit file truncation, a policy written without the relevant supported right cannot be assumed to deny it. Determine the running ABI, include only rights supported by that ABI, and explicitly account for any unhandled operation. If a required right is unavailable, fail closed or clearly document the weaker isolation; silently omitting it changes the threat model. A sandboxed process may already hold an open object before restriction, and the Landlock documentation specifies which operations and object access patterns are mediated. A policy should account for such preexisting descriptors instead of assuming path-based restrictions retroactively close them.
When a broker uses notification IDs, it must treat a failed validity check or reply as a cancellation race, not retry blindly against a recycled identifier. Continuing a target syscall after copying a pathname leaves a time-of-check/time-of-use window. A broker performing its own operation also needs a defensible relationship between the inspected target context and the object it opens. In either design, record which thread and namespace performed the operation and how the result was transferred; otherwise the audit trail can misattribute the authority used.
Practice#
A real child-only seccomp filter
On an authorized Linux host or disposable guest with a C compiler and Linux UAPI headers, the following creates and runs one temporary executable. It supports only a native x86-64 build, checks the audit architecture before checking the syscall number, sets no_new_privs inside the new process, and asks for EPERM on a direct getpid syscall. The parent shell is not filtered. It does not attempt to modify files except its own temporary build directory. The architecture check is suitable for this narrow demonstration, not a production x32-compatible sandbox.
seccomp_lab_dir=$(mktemp -d) || exit 1
trap 'rm -rf -- "$seccomp_lab_dir"' EXIT
cat > "$seccomp_lab_dir/test.c" <<'C'
#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
#if !defined(__x86_64__) || defined(__ILP32__)
#error This demonstration requires native x86-64
#endif
int main(void) {
struct sock_filter code[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = { .len = (unsigned short)(sizeof(code)/sizeof(code[0])), .filter = code };
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("no_new_privs"); return 77; }
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) { perror("seccomp install"); return 77; }
errno = 0;
long ret = syscall(SYS_getpid);
int err = errno;
printf("direct getpid returned %ld; errno %d\n", ret, err);
return ret == -1 && err == EPERM ? 0 : 1;
}
C
cc -Wall -Wextra -O2 "$seccomp_lab_dir/test.c" -o "$seccomp_lab_dir/test" && "$seccomp_lab_dir/test"
On a supported, permitted native x86-64 system, expected output reports return -1 and errno 1 (EPERM) and exit status zero. Missing compiler or headers, a different architecture, disabled kernel configuration, a pre-existing container restriction, or EPERM during installation are environment limitations, not proof of the tested filter's runtime behavior. Exit 77 signals an installation prerequisite failed; a compiler error is also not a sandbox escape. Do not alter policy to overcome these cases. The trap removes only the newly created temporary directory. This demonstration does not install Landlock or change audit rules.
For an optional read-only status check, grep -E '^(NoNewPrivs|Seccomp|Seccomp_filters):' /proc/self/status shows the querying process's status, which may already reflect an outer sandbox. It does not disclose the filter program or prove its policy. A Landlock ABI query can be added in a separately compiled child using the version syscall and runtime header availability, but this lesson makes no claim that Landlock is active on your host.
Source-only alternative: evaluate the two supplied branches: TSYNC plus NEW_LISTENER without TSYNC_ESRCH returns -EINVAL; a Landlock version query on initialized v6.12 returns 6. Explain why a user-notification pathname pointer can change after inspection. Deliver those outcomes and assumptions, or the actual test's return and installation result with kernel and architecture. Distinguish a failed installation from a filter that installed and denied the target syscall.
Knowledge check#
- Why must a seccomp filter validate the syscall architecture before comparing numbers?
- Can a notification listener authorize a pathname safely merely by reading its pointer once and continuing the syscall?
- What does a Landlock handled-access mask mean for an omitted right?
- Why does v6.12 reject the quoted TSYNC/listener flag combination?
- Does a missing audit record establish that an operation did not occur?
Answers#
- Syscall numbers and calling conventions vary by ABI. Without validation, a number interpreted under the wrong architecture can match the wrong operation; compatibility ABIs need additional handling.
- No. The pointer's target data can change between inspection and the eventual operation. Notification identity validation handles a different race and does not freeze memory.
- The omitted right is not restricted by that layer merely because it has no allow rule. Other layers, DAC or other LSMs can still deny it; ABI-specific rules and object semantics matter.
- A successful listener request returns an fd while a TSYNC failure can return a thread identifier; the specified flag avoids ambiguous error reporting for the combination.
- No. Rules, collection, backlog, loss and availability affect whether the event was recorded and retained.
Checkpoint#
Submit either the child test result or the two source-branch outcomes, plus a handled-rights table and notification race explanation. Ready means you distinguish install failure from syscall denial, unhandled from denied Landlock rights, and audit observation from authorization. Retry the source branches if any assumption is missing. This is not certification.