Skip to main content

Seccomp

Simple Story

A filter sits between the program and the kernel and reads every request before the kernel does.

It can answer a request itself, and it can pass the request to somebody outside who answers on the program's behalf. Phobos uses both, for different jobs.

What it is​

Seccomp filters system calls. A process installs a classic Berkeley Packet Filter program that the kernel runs on every call, over a small structure holding the call number, its arguments and the architecture it came from. The filter returns an action: allow the call, fail it with an error number, kill the process, or notify a supervising process and wait for its answer.

Installing one needs no privilege, provided PR_SET_NO_NEW_PRIVS is set first. A filter is inherited across fork and survives execve, which is what lets Phobos install one high in the chain and have it hold all the way down to the command.

Use one: the process-group lock​

phobos-seccomp-timeoutsystem installs the smallest filter in the repository and then becomes the rest of the chain:

if arch != <native> -> refuse
if syscall is setsid -> refuse (a supervisor can answer it from its ledger)
if syscall is setpgid -> refuse (the same)
otherwise -> allow

"Refuse" is a user notification to a listener the lock does not have, so the kernel itself answers it with ENOSYS. A supervisor further down the chain traps the same calls with a listener of its own. That supervisor is the connect guard or the report-only supervisor of the filesystem layer. It is the newer filter, so it wins the tie, answers EACCES and reports the call. Used on its own, the lock refuses with ENOSYS and reports nothing.

The first line matters as much as the other two. Syscall numbers differ between one application binary interface (ABI) and another, so a setsid made through an alternate one, the 32-bit entry on x86-64 or x32, would slip past a comparison written against the native numbers. Refusing every non-native ABI outright closes that.

The refusal is an error, ENOSYS from the kernel alone or EACCES from a supervisor, rather than a kill: the command sees an error and can report it.

Why those two calls: the timeout layer bounds a run by signalling a whole process group, and a process that starts a new session or group leaves that group. Without the lock, a timed command could outlive its limit through a detached child.

Use two: the connect guard​

The connect guard uses the other action, SECCOMP_RET_USER_NOTIF. Its filter traps a call to a notification file descriptor, and a supervisor outside the sandbox reads the notification, decides, and answers.

The guard forks before it installs anything. The child installs the filter, passes the notification descriptor up to the parent over a UNIX socket pair, and execs the rest of the chain. The parent becomes the supervisor and is never restricted.

What the filter traps or refuses:

CallActionWhy
connectnotifythe decision the guard exists to make
sendto with an address, sendmsg, sendmmsgnotifya datagram carries its own destination, and TCP Fast Open can open a connection past a connect; the supervisor sends them itself
sendto with no addressallowthe send of a connected socket, whose peer the supervisor vetted when it made the connect; the address pointer is a register the command cannot change
socketnotifya raw, packet or ICMP socket is refused before it exists, and a stream socket is created by the supervisor so it can be held
listennotifythe supervisor runs it on its own descriptor for a socket it holds, so a socket never bound cannot become a listener on a port nothing judged
io_uring_setup and its siblingsrefusea second syscall interface that would reach connect unseen
setsid, setpgidnotifythe supervisor answers from its ledger of virtual groups and never runs the call, or refuses it where it cannot continue a call
getpgid, getsid, getpgrpnotifythe supervisor answers for a process in its ledger and passes the call on for any other
kill with a pid of zero or belownotifya signal to a virtual group goes to its members through process descriptors, and the kernel takes anything else

The supervisor connects on the command's behalf rather than letting the call continue. Seccomp offers SECCOMP_USER_NOTIF_FLAG_CONTINUE, which re-runs the original call, and that would re-read the destination from the command's memory at a later moment: a command could show one address to the check and connect to another once it passed. Making the connection in the supervisor, from the address the check read, closes that window. The connected socket is handed back with SECCOMP_IOCTL_NOTIF_ADDFD.

A datagram connect or send is different: it sets a default peer or names one, which cannot be injected the same way, and CONTINUE would leave the window the caveat above describes, which measured at about one datagram in six reaching an address the allow-list never named. So the supervisor never continues one. It creates every datagram socket, keeps its own descriptor for the same open file description, copies the address, the data and the lengths out of the command with process_vm_readv once, checks the copies, and runs the connect or sendto itself on that descriptor, answering with what the kernel answered. Both descriptors name one open file description, so a connect it makes is the command's own, and the command's later address-less send() reaches the peer the supervisor vetted. For sendmmsg it writes each msg_len back with process_vm_writev, and a write that fails ends the batch, as the kernel does.

The same reason decides what it refuses. sendmsg and sendmmsg carry their address inside memory, so on a socket the supervisor does not hold, TCP and UNIX sockets included, they are refused: a second thread could swap a datagram socket under the descriptor and name a destination. Ancillary data is refused because it can steer a datagram, and a send that would give an unbound socket a source port is refused unless the policy granted one, because the supervisor runs outside Landlock.

Reading the destination out of the child's memory uses process_vm_readv, which a parent may do to its own child in an ordinary container. No capability, no container flag.

Use three: the report-only supervisor​

phobos-seccomp-filesystem uses the same mechanism as the guard for a different purpose. It traps the path calls of the command with a user notification and reads the path from the command's memory. It mirrors the Landlock decision only to decide whether to print a Phobos Security Error line, and it always answers CONTINUE, so the kernel and Landlock decide. When the group lock is present, the supervisor takes over the lock's refusals as well. A run has one effective listener, so the supervisor runs only when the network layer is off. With the network layer on, the connect guard is the run's one supervisor and does this work too: it links the same reporter, adds bind and, with --report-filesystem, the path calls to its own filter, and answers its own refusals (io_uring, a foreign ABI) with EACCES through the reporter's refusal handler, which has no way to continue a call. It answers the group calls from its ledger as well when it can continue a call. Each kind of trap has one answer: observation traps CONTINUE, refusal traps EACCES, and the guard's enforcement traps as before.

Where seccomp sits relative to Landlock​

Seccomp intercepts a call at the syscall boundary, before the kernel path where Landlock would check a port. The supervisor therefore connects outside Landlock, which is why the guard is the whole connect boundary where it runs, host and port together, and why the Landlock port rules remain a second, kernel-enforced expression of the same ports rather than the only one.

Both filters stack​

The connect guard traps setsid and setpgid for the command it supervises, and so does the process-group lock. That is not duplication to remove: the guard's cover belongs to the network layer, and the lock's belongs to the timeout layer, so a run with no supervisor keeps the group kill unescapable. Where both filters speak, the kernel takes the stricter action. The lock refuses with ENOSYS only when no newer filter has a listener to ask; the supervisor's listener answers the notification when there is one.

Further reading​