Seccomp and Landlock
fwatch sandboxes itself with two independent, unprivileged kernel layers -- a seccomp-bpf syscall allowlist built from a real strace profile, and a Landlock ruleset that restricts filesystem reads to one directory -- probing the Landlock ABI at runtime instead of assuming it, and proving both with negative controls that watch the exact denial happen.
Chapter 32 gave fwatch its own view of the process tree and its own
resource limits through namespaces and cgroup v2 – isolation the kernel
grants to a group of processes. This chapter hands the same idea to a
single process, no grouping required: watch --sandbox DIR now installs
a syscall allowlist and a filesystem allowlist against itself, using two
mechanisms any unprivileged process can invoke directly. seccomp-bpf
decides, syscall by syscall, whether the calling process is allowed to make
this call at all. Landlock decides, path by path, which parts of the
filesystem that process may still read. Neither needs root, a namespace, or
a VM – which is also why every number in this chapter was reproduced on
the plain host and confirmed unchanged on the systems-target lab guest.
The one new idea worth sitting with: seccomp-bpf is classic BPF, the
same in-kernel bytecode VM whose modern descendant, eBPF, this book has so
far only used as an observation tool (bpftrace, bcc-tools, bpftool
in Part 8). A seccomp filter is the mirror image – you are not attaching a
program that watches a syscall and reports it, you are attaching a program
that decides the syscall’s outcome, on the hot path, before the kernel
does the work. Same VM, same verifier discipline, opposite job.
The code is in examples/33-seccomp-and-landlock/. The run script there
builds/sets up and runs it; its README.md covers the CLI, the exit-code
contract, and the paired positive/negative verification in full.
Tools used —
strace(host andsystems-targetVM, to build and confirm the syscall profile),cmake+ninja/go build/cargo(the Python runner’s build phase, host, all three toolchains),ssh/scp(viascripts/test-all-examples.py --mode vmand its staging step ontosystems-target), a small ad hocgcc-built C program (host and guest, to observeSCMP_ACT_KILL_PROCESS),grep//proc/<pid>/status(both). Everything here ships with Fedora’s toolchain or is preinstalled on the lab guest perreferences/vm-lab.md; nothing needs root.
seccomp-bpf: the filter you write
seccomp(2) installs a cBPF program that the kernel runs on every
syscall the process makes from that point on. The program reads two fields
out of struct seccomp_data – the architecture and the syscall number –
and returns one of a small set of verdicts: SECCOMP_RET_ALLOW (run it),
SECCOMP_RET_ERRNO (fail it with a chosen errno, without ever entering the
real syscall handler), or SECCOMP_RET_KILL_PROCESS (end the process with
SIGSYS, no return). Once a filter is loaded it can only ever be
tightened – filters stack, and a later one can narrow what an earlier
one allowed but never widen it.
fwatch’s allowlist did not come from guessing. The comment in the C++
source says it directly: the syscall set is “empirically confirmed with
strace -f across a full watch session.” That is the practical way to
build a seccomp profile – run the real workload under strace -f -c,
read off the syscalls it actually issues, and allowlist exactly those.
Reproduced on the host against the unsandboxed watch loop:
[host]$ strace -f -c ./cpp/build/release/app watch /tmp/lsp33-profile --timeout-ms 2000
fwatch: watching /tmp/lsp33-profile
fwatch: exiting (timeout)
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
37.74 0.000080 8 10 close
13.21 0.000028 4 7 fstat
12.74 0.000027 9 3 write
11.79 0.000025 4 6 read
9.43 0.000020 20 1 epoll_wait
3.77 0.000008 2 3 epoll_ctl
3.30 0.000007 7 1 inotify_add_watch
2.36 0.000005 5 1 inotify_init1
1.42 0.000003 3 1 timerfd_create
1.42 0.000003 3 1 signalfd4
1.42 0.000003 3 1 epoll_create1
0.94 0.000002 2 1 timerfd_settime
0.47 0.000001 1 1 rt_sigprocmask
0.00 0.000000 0 22 mmap
0.00 0.000000 0 7 mprotect
0.00 0.000000 0 1 munmap
0.00 0.000000 0 3 brk
... (dynamic-linker startup:
execve, openat, pread64,
access, arch_prctl, …)
------ ----------- ----------- --------- --------- ------------------
100.00 0.000212 2 87 1 total
That is exactly the source of kWatchSyscalls in examples/33-seccomp-and-landlock/cpp/src/main.cpp
(read/write/close, the inotify/timerfd/signalfd trio, epoll’s three
calls, the allocator’s mmap/munmap/brk, and fstat/rt_sigprocmask).
The rows elided above (execve, openat, pread64, access,
arch_prctl, set_tid_address, set_robust_list, prlimit64, rseq) are
the dynamic linker resolving libc/libstdc++ before main runs –
real syscalls this same process makes, but not ones the watch loop itself
issues, so they are outside what the filter needs to allow once it loads
after startup.
install_seccomp (same file) calls seccomp_init(SCMP_ACT_ERRNO(deny_errno))
– that argument is the filter’s default action, applied to anything
not explicitly allowed – then seccomp_rule_add(ctx, SCMP_ACT_ALLOW, nr, 0)
once per syscall, and seccomp_load(ctx) to install it. seccomp_load
requires no_new_privs first (set via prctl), a kernel guard against a
sandboxed process regaining privilege through a setuid binary it execs.
SCMP_ACT_ERRNO versus SCMP_ACT_KILL
fwatch deliberately chose SCMP_ACT_ERRNO(EPERM), not
SCMP_ACT_KILL_PROCESS, as its default action, and the difference is
observable, not cosmetic. With ACT_ERRNO, a denied syscall returns -1
with errno set and the calling code continues to run – the process can
log the denial, clean up, and exit on its own terms; that is what makes
probe --forbidden-syscall’s own EPERM-and-continue behavior possible.
With ACT_KILL_PROCESS, a denied syscall never returns at all: the kernel
delivers an uncatchable SIGSYS and the process dies mid-instruction. To
see the contrast for real rather than assert it, a small standalone probe
(not part of fwatch, built only to observe this) installs a filter
allowing only write/exit_group/rt_sigreturn under
SCMP_ACT_KILL_PROCESS, then calls the unlisted getpid():
[vm]$ strace -f -e trace=getpid /tmp/kill-demo 2>&1 | tail -3
before getpid
getpid() = 39
+++ killed by SIGSYS (core dumped) +++
strace shows the syscall entering the kernel and never returning –
the trailing line is strace reporting the process’s death, not a normal
return value. ACT_ERRNO is the right choice whenever the caller has
useful cleanup to do or a human is watching the diagnostic; ACT_KILL is
the right choice when a denied syscall means the process’s memory can no
longer be trusted (a common posture for hardened service sandboxes) and
continuing at all is the greater risk.
Landlock: rulesets, rules, and an ABI you probe
Landlock restricts what a process (and everything it execs from here on)
may still do to the filesystem, expressed as three calls with no
convenience wrapper in any of the three languages’ standard libraries –
landlock_create_ruleset, landlock_add_rule, landlock_restrict_self,
reached through a raw syscall(2) in C++ and Go and through the landlock
crate (which itself wraps the same three numbers) in Rust. A ruleset
declares which access rights this program intends to handle at all –
fwatch handles only LANDLOCK_ACCESS_FS_READ_FILE and _READ_DIR, and
leaves handled_access_net at zero. A rule then grants a subset of
those handled rights to one filesystem object, here a PATH_BENEATH rule
binding read rights to a directory fd. restrict_self is the point of no
return: it activates the ruleset against the calling process and every
descendant, and there is no syscall to lift it – the only way out is
execve-ing a program that never asked for the restriction to begin with,
and even then the new program inherits the same ruleset.
The ABI matters here in a way it rarely does elsewhere in this book, and the chapter’s own two machines make the point better than any prose:
[host]$ ./cpp/build/release/app probe --sandbox /tmp --outside /etc/hostname
fwatch: landlock ABI=9 enforced
fwatch: probe outside /etc/hostname: EACCES (Permission denied)
[vm]$ /tmp/lsp33-cpp/app probe --sandbox /tmp --outside /etc/hostname
fwatch: landlock ABI=7 enforced
fwatch: probe outside /etc/hostname: EACCES (Permission denied)
Same distribution, same syscall numbers, and yet the host (kernel
7.1.3-200.fc44.x86_64) reports ABI 9 while systems-target (kernel
6.19.10-300.fc44.x86_64) reports ABI 7 – two real Fedora 44 kernels a
few point releases apart, disagreeing about what Landlock supports, while
agreeing completely on the observable behavior the ruleset enforces. Every
call in this example reaches that number the only real way: calling
landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) and
reading back the version the running kernel returns, never a compile-time
constant. A binary that assumed “ABI 9 because that’s what built it” would
misbehave the moment it ran on the guest.
What Landlock can and cannot do. Filesystem mediation –
open/read/write/execute/rename, scoped to a path – has been solid since
ABI 1 (Linux 5.13). Network mediation is newer and arrived in pieces:
LANDLOCK_ACCESS_NET_BIND_TCP/_CONNECT_TCP since ABI 4, but UDP
bind/connect rights not until ABI 10; fwatch targets neither, so on both
of these machines a Landlock-restricted process can still open arbitrary
sockets outside the tree entirely – that gap is exactly why fwatch
layers seccomp underneath it rather than relying on Landlock alone.
IPC-scoping flags (restricting signals and abstract-socket connections
between sandboxed and unsandboxed processes) arrived at ABI 6, so both
machines here have them even though neither example uses them. The
practical rule this chapter teaches: probe the ABI, then gate each
feature on the version number it actually needs – never gate on “did
landlock_create_ruleset return non-negative.”
How the code works
Both mechanisms share one setup shape across all three languages: probe the Landlock ABI, build and enforce the ruleset, then install the seccomp filter, each step logging a status line before the identical epoll watch loop from chapter 09 runs unmodified. C++ and Rust do this in-process with raw syscalls; Go does something structurally different, and the concurrency lens below explains why.
// Create a ruleset that handles READ_FILE + READ_DIR (available since ABI
// v1), add one path-beneath rule granting exactly those rights under `dir`,
// then restrict_self. On success the calling process — and everything it
// execs from here on — can only read inside `dir`.
[[nodiscard]] std::expected<void, std::error_code> apply_landlock(const std::string& dir) {
constexpr std::uint64_t access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR;
landlock_ruleset_attr attr{.handled_access_fs = access, .handled_access_net = 0, .scoped = 0};
const long rs = sys_landlock_create_ruleset(&attr, sizeof(attr), 0);
if (rs < 0) {
return std::unexpected(last_error());
}
Fd ruleset{static_cast<int>(rs)};
auto dirfd = checked(::open(dir.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC));
if (!dirfd) {
return std::unexpected(dirfd.error());
}
landlock_path_beneath_attr pb{.allowed_access = access, .parent_fd = dirfd->get()};
if (sys_landlock_add_rule(ruleset.get(), LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) {
return std::unexpected(last_error());
}
dirfd->reset();
// Landlock requires no_new_privs (or CAP_SYS_ADMIN) before restrict_self.
if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
return std::unexpected(last_error());
}
if (sys_landlock_restrict_self(ruleset.get(), 0) < 0) {
return std::unexpected(last_error());
}
return {};
}
// installSeccomp loads an allowlisting filter with SECCOMP_FILTER_FLAG_TSYNC
// so every OS thread already running in this process is covered, not just
// the calling one. Returns the number of syscalls admitted.
func installSeccomp(allowed []uint32, denyErrno uint32) (int, error) {
// seccomp(2) requires no_new_privs (or CAP_SYS_ADMIN), same as Landlock;
// harmless to set again if applyLandlock already did.
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return 0, fmt.Errorf("prctl(PR_SET_NO_NEW_PRIVS): %w", err)
}
prog := buildAllowlist(allowed, denyErrno)
fprog := sockFprog{Len: uint16(len(prog)), Filter: &prog[0]}
_, _, errno := unix.Syscall(unix.SYS_SECCOMP, uintptr(unix.SECCOMP_SET_MODE_FILTER),
uintptr(unix.SECCOMP_FILTER_FLAG_TSYNC), uintptr(unsafe.Pointer(&fprog)))
if errno != 0 {
return 0, errno
}
return len(allowed), nil
}
/// Installs an allowlist of `allowed` syscalls; everything else returns
/// errno `deny_errno`. Returns the number of syscalls admitted.
fn install_seccomp(allowed: &[i64], deny_errno: i32) -> Result<usize> {
let rules = allowed.iter().map(|&nr| (nr, vec![])).collect();
let filter = SeccompFilter::new(
rules,
SeccompAction::Errno(deny_errno as u32),
SeccompAction::Allow,
TargetArch::x86_64,
)
.context("seccomp: build filter")?;
let program: BpfProgram = filter.try_into().context("seccomp: compile filter")?;
seccompiler::apply_filter(&program).context("seccomp: install filter")?;
Ok(allowed.len())
}
C++’s apply_landlock is the clearest read of the three-call shape: build
the landlock_ruleset_attr naming the rights this program is willing to
have restricted at all (handled_access_fs, leaving handled_access_net
at zero – an explicit, auditable “we do not sandbox network here”, not an
oversight), open the target directory O_PATH-only (no read/write access
needed just to name it as a rule target), bind the rule with
landlock_add_rule, and only then call prctl(PR_SET_NO_NEW_PRIVS) and
landlock_restrict_self – in that order, because restrict_self demands
no_new_privs already be set. Rust’s landlock crate (source in
rust/src/main.rs) compresses the same four calls into one builder chain
– Ruleset::default().handle_access(...).create()?.add_rule(...)?.restrict_self()?
– and adds one thing the raw syscall path does not give you for free: it
checks the returned RulesetStatus, so a kernel that silently
best-effort-ignores an unsupported right is caught (bail! if
status.ruleset != RulesetStatus::FullyEnforced) instead of quietly
running unsandboxed.
Go’s installSeccomp is the odd one out for a reason worth reading
closely: golang.org/x/sys/unix exposes the raw seccomp(2) syscall
number but no BPF assembler, so buildAllowlist (same file) hand-emits the
cBPF program byte for byte – BPF_LD|BPF_W|BPF_ABS to load the syscall
number, one BPF_JMP|BPF_JEQ comparison per allowed syscall that jumps to
RET_ALLOW on a match, and a fall-through RET_ERRNO. That is, in
miniature, exactly what libseccomp and seccompiler compile down to
underneath their friendlier APIs – Go’s version just makes the compiled
form visible because nothing hides it. The other detail to notice is
SECCOMP_FILTER_FLAG_TSYNC in the syscall arguments and its Landlock
counterpart in applyLandlock: both matter only because of what the Go
runtime does before main ever runs, covered next.
Errors, three ways
Every failure path in this example distinguishes three outcomes, and the
distinction is what makes a negative control trustworthy rather than
decorative. A setup failure – Landlock unsupported, seccomp_load
rejecting a malformed filter, a permission error opening the sandbox
directory – is a real bug and exits 1 with the syscall name and
strerror text, identically formatted across languages (SysErr-style in
C++/Rust, a wrapped error in Go). A confirmed denial – the sandbox
did exactly what it claims, EPERM on the forbidden syscall or EACCES on
the outside path – exits 20, fwatch’s own “probe passed” convention.
An unconfirmed denial – the probed operation succeeded when it
should have been blocked – exits 21: a real sandboxing bug, distinct
from both a clean pass and an unrelated error. Reusing the ordinary 0/1/2
contract for this would erase exactly the distinction the chapter is about:
a script that only checks “exit code is nonzero” cannot tell “the sandbox
worked” from “the sandbox is broken.”
Concurrency lens
Both Landlock and seccomp are, by default, per-thread kernel state –
they restrict the calling thread, not automatically every thread in the
process. That is invisible in C++ and Rust here because both builds are
deliberately single-threaded before the sandbox goes on: main calls
apply_landlock and install_seccomp before the epoll loop starts any
concurrency of its own, so “the calling thread” and “the whole process”
are the same thing.
Go cannot make that assumption, and its main.go says so in a comment
worth quoting directly: “the Go runtime schedules goroutines across OS
threads it creates and destroys on its own schedule (GC workers, sysmon,
threads parked in blocking syscalls) – applying either sandbox to only the
calling thread would leave those other threads unrestricted, and a
goroutine the runtime later migrates onto one of them would silently run
outside the sandbox.” The fix is two-part, mirroring the C++/Rust identity
lesson from chapter 14 almost exactly: runtime.LockOSThread() pins the
sandboxing goroutine to one OS thread so the thread issuing the Landlock
and seccomp syscalls never changes out from under it mid-sequence, and
both landlock_restrict_self’s TSYNC flag and SECCOMP_FILTER_FLAG_TSYNC
apply the new restriction to every thread already running in the process
atomically, not just the caller’s. applyLandlock tries TSYNC first and
falls back to per-thread-only only if the running kernel rejects the flag
– a second, smaller instance of “probe, don’t assume.” Confirmed
behaviorally, not just by reading the flag names: a goroutine the runtime
schedules onto a different OS thread after the sandbox is installed
still gets EACCES/EPERM on the restricted operations, because TSYNC
already covered that thread before it existed as a goroutine destination.
Build, run, observe
[host]$ LIBVIRT_DEFAULT_URI=qemu:///system \
python3 scripts/test-all-examples.py --only 33-seccomp-and-landlock --mode vm
Against systems-target this session (the build phase compiles cpp with
cmake+ninja, go with go build, and rust with cargo build --release
before any of the three ever touches the guest):
building 3 example-lang combinations (jobs=1)...
build 33-seccomp-and-landlock [cpp]: ok
build 33-seccomp-and-landlock [go]: ok
build 33-seccomp-and-landlock [rust]: ok
verifying...
verify 33-seccomp-and-landlock [cpp]: PASS
verify 33-seccomp-and-landlock [go]: PASS
verify 33-seccomp-and-landlock [rust]: PASS
example cpp go rust
33-seccomp-and-landlock PASS PASS PASS
3 passed, 0 failed, 0 skipped (logs in build-logs/)
By hand, run-sandbox-checks.sh staged and run as root on the guest (the
book’s convention for every namespace/cgroup/seccomp/Landlock demo, even
though neither mechanism here actually requires root):
[vm]$ sudo bash /tmp/lsp33-run-sandbox-checks.sh /tmp/lsp33-cpp/app
=== forbidden-syscall ===
fwatch: probe forbidden-syscall: EPERM (Operation not permitted)
fwatch: seccomp filter installed (26 syscalls allowed)
forbidden-syscall-exit=20
=== outside ===
fwatch: landlock ABI=7 enforced
fwatch: probe outside /tmp/lsp33-outside-target/secret.txt: EACCES (Permission denied)
outside-exit=20
=== watch-sandbox (positive control: watching inside the tree still works) ===
fwatch: landlock ABI=7 enforced
fwatch: seccomp filter installed (26 syscalls allowed)
fwatch: watching /tmp/lsp33-watch-sandbox
event: deleted inside.txt
fwatch: exiting (timeout)
watch-sandbox-exit=0
The third block is the control that makes the first two mean anything: sandboxing that broke the watch loop itself would be worthless no matter how convincingly it denied the two probes.
Cross-check on systems-target
strace and /proc are independent of fwatch’s own exit-code claims,
so both are worth reading directly against the same guest run. First, the
denied syscall itself, traced from outside the process:
[vm]$ strace -f -e trace=socket,seccomp /tmp/lsp33-cpp/app probe --forbidden-syscall 2>&1 | tail -4
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=34, filter=0x26638170}) = 0
fwatch: seccomp filter installed (26 syscalls allowed)
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = -1 EPERM (Operation not permitted)
fwatch: probe forbidden-syscall: EPERM (Operation not permitted)
Second, the kernel’s own record of the sandbox state, read from a long-lived sandboxed process rather than a probe that exits immediately:
[vm]$ /tmp/lsp33-cpp/app watch --sandbox /tmp/lsp33-status-check --timeout-ms 3000 &
[vm]$ grep -i seccomp /proc/$!/status
Seccomp: 2
Seccomp_filters: 1
Seccomp: 2 is SECCOMP_MODE_FILTER (mode 1 is the older, filter-less
“strict” mode; 0 is unsandboxed) – /proc confirms the filter is live
independently of anything the process prints about itself.
ip_unprivileged_port_start-style sysctl shortcuts do not apply here since
neither mechanism has a global toggle to check against; the closest
equivalent negative control not shown above is simply repeating the
outside probe against a path inside the sandboxed tree, which the demo
script’s third block already exercises implicitly by watching successfully
there.
What you learned
- Seccomp-bpf is classic BPF running the opposite job from the eBPF tools
in Part 8: a program you write decides each syscall’s outcome
(
SECCOMP_RET_ALLOW/_ERRNO/_KILL_PROCESS) instead of merely observing it, built from a realstrace -f -cprofile of the workload, not a guess. SCMP_ACT_ERRNOlets a denied call return and the caller keep running;SCMP_ACT_KILL_PROCESSends the process onSIGSYSwith no return – confirmed by watchingstraceshowgetpid()never return in the kill-mode probe.- Landlock’s ruleset/rule/restrict_self model is layered access control you
build yourself, and the ABI must be probed at runtime
(
landlock_create_ruleset(NULL, 0, VERSION)) rather than assumed – this session’s own host andsystems-targetguest returned different versions (9 vs 7) from the same distribution. - Both mechanisms are per-thread by default; a single-threaded process
never notices, but Go’s always-threaded runtime needs
runtime.LockOSThread()plus theTSYNCflags to cover threads the scheduler creates around the sandboxing call – the same “identity is per-thread, not per-process” lesson chapter 14 taught for capabilities.
Next, examples/34-our-programs-in-containers: the same daemons this
book has built, running inside rootless Podman containers built from
multi-stage UBI 10 Containerfiles – where seccomp and Landlock profiles
like this chapter’s stop being something a program installs on itself and
start being something a container runtime installs for it.
verified — every transcript
and number above was produced this session, reproduced on both the plain
host (kernel 7.1.3-200.fc44.x86_64, libseccomp 2.6.0, GCC
16.1.1, Go 1.26.5, Rust/Cargo 1.97.1) and the systems-target
guest (kernel 6.19.10-300.fc44.x86_64): the runner printed
33-seccomp-and-landlock PASS PASS PASS (3 passed, 0 failed,
0 skipped) in vm mode; run-sandbox-checks.sh on the guest
produced forbidden-syscall-exit=20 with the printed
EPERM line, outside-exit=20 with the printed
EACCES line, and watch-sandbox-exit=0 with an
event: deleted inside.txt line, byte-identical in shape across
cpp/go/rust (allowed-syscall counts differ: 26 for cpp/rust, 39 for go);
strace on the guest showed socket(...) = -1 EPERM
under the installed filter and, in a separate ad hoc
SCMP_ACT_KILL_PROCESS probe built for this chapter,
getpid() followed by killed by SIGSYS (core dumped);
/proc/<pid>/status read Seccomp: 2 with
Seccomp_filters: 1 for a live sandboxed process; and the
Landlock ABI probe returned 9 on the host versus 7 on
systems-target, read live via
landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)
in every language, never hardcoded. Landlock's network-rights history
(TCP since ABI 4, UDP since ABI 10) and current crate/library pins
(landlock 0.4.5, seccompiler 0.5.0, libseccomp
2.6.1 as the latest upstream release) were confirmed against upstream
sources at authoring time and are worth re-checking before a future
release, as with any dependency pin in this book.