System calls as an attack surface

On Linux, the kernel exposes a well-defined set of system calls that userspace applications use to interact with hardware, storage, networking, and other kernel services. This interface is the only sanctioned path for most privileged operations, and it is also where the kernel enforces resource management and security policies. However, the default posture is permissive: unless an application requests otherwise, the kernel will happily service almost any system call it makes.

That permissiveness becomes a problem when an application is compromised. An image converter that reads files from disk and writes results locally has no legitimate need to open network sockets. But if an attacker gains code execution inside that process, the kernel will, by default, allow send and recv calls, letting the attacker exfiltrate data. The application itself never needed that capability, and the kernel had no way to know that.

Declaring intent with seccomp

Linux seccomp lets an application declare its intended system call usage in advance, establishing a contract with the kernel. The application provides a BPF program, which the kernel validates and then executes against every subsequent system call. Depending on the filter's outcome, the kernel can:

  • terminate the entire process or just the offending thread
  • deliver a SIGSYS signal to the calling thread
  • return a specified error code instead of performing the call
  • notify a ptrace tracer (such as a debugger) and let it decide
  • allow the call but log the attempt, which is useful for testing policy tightness without risking downtime

From a security standpoint, the strongest response is usually immediate termination. Returning an error code and letting the process continue gives an attacker room to work around the policy, which we will see demonstrated below.

A minimal example: raw seccomp

Consider a tiny utility that simply prints the operating system name. It works by calling the uname system call, and in normal operation that call cannot fail: the buffer it passes is a valid stack-allocated structure. So if uname returns an error, something unusual is going on.

We can add a sandbox function that installs a raw BPF filter before the main logic. The filter checks the system call number. If the call is uname, the kernel is instructed to return an EPERM error; any other call is allowed. Running the program, uname fails with EPERM, an error code that is not even listed as a possible failure in the uname man page. Changing the policy to return ENETDOWN ("network is down") produces the same kind of failure, clearly unrelated to any real networking issue, confirming the filter is doing its job.

The permissive half of the policy also works: the program prints its error message via the write system call, which the filter allows.

A cleaner approach: libseccomp

Writing BPF filters by hand is error-prone and difficult to review. The libseccomp library, recommended by the seccomp man page, abstracts that away. Rules can reference system calls by name rather than by raw number, and the library handles details like the PR_SET_NO_NEW_PRIVS flag and architecture-specific quirks.

Rewriting the sandbox function with libseccomp makes it shorter and more readable. But the example also changes the enforcement action: instead of returning an error code, the policy now tells the kernel to kill the process on any violation.

This is not an arbitrary choice. Returning an error code from a denied system call is often insufficient, because related system calls can bypass the filter. Suppose a policy denies read and returns an error code. If the application is compromised and tries to read data, the call fails. But Linux has multiple read-like calls: read, pread, readv, and more obscure ones such as io_submit and io_uring_enter. A determined attacker could probe the policy and find an allowed variant that achieves the same result. If the kernel instead terminates the process immediately upon the first denied read, the attacker never gets a second attempt.

With the stricter policy in place, running the program no longer prints a friendly error message. The shell reports Bad system call, indicating the process died from a SIGSYS signal as directed by the seccomp filter.

The Visibility Gap in Seccomp Policies

Seccomp policies are inherently tied to the process they protect, which creates a practical problem. The seccomp syscall only affects the calling process and its children; there is no interface for an external party to attach a policy to a running or about-to-run process. The design intent is that developers embed sandboxing logic into their applications. In reality, this rarely happens. New projects prioritize core features, and security measures are often delayed or dropped entirely. Moreover, most applications are built with high-level languages and frameworks where developers never interact with syscalls directly and may not even know which ones their code relies on.

This leaves system operators—sysadmins, SREs, and others who run this software in production—in a difficult position. They have a strong incentive to minimize the attack surface of their services, but they usually lack access to the source code needed to implement a seccomp policy. The result is a mismatch: those who can sandbox the code typically don't, and those who want to sandbox it typically can't.

Zero-Code Seccomp with Systemd

Systemd offers a "zero-code" solution to this problem. For services managed by systemd, operators can define a SystemCallFilter= directive in the service's unit file, listing the syscalls the service is permitted to use. This allows an external operator to enforce a policy without recompiling or altering the application.

Consider the same toy application that prints its OS name via the uname syscall, but this time without any embedded seccomp filter. Using systemd-run, we can launch it as an ephemeral service and deny uname:

$ systemd-run --user --pty --same-dir --wait --collect --service-type=exec --property="SystemCallFilter=~uname" ./myos
Running as unit: run-u0.service
Press ^] three times within 1s to disconnect TTY.
Finished with result: signal
Main processes terminated with: code=killed/status=SYS
Service runtime: 6ms

The expected output never appears. Instead, systemd reports that the process terminated with a SIGSYS signal. With the SystemCallErrorNumber= directive, we can change the behavior from killing the process to returning a specific error code, mirroring the raw seccomp approach:

$ systemd-run --user --pty --same-dir --wait --collect --service-type=exec --property="SystemCallFilter=~uname" --property="SystemCallErrorNumber=ENETDOWN" ./myos
Running as unit: run-u2.service
Press ^] three times within 1s to disconnect TTY.
uname failed: Network is down
Finished with result: exit-code
Main processes terminated with: code=exited/status=1
Service runtime: 6ms

Systemd's Implicit Whitelist

While powerful, the systemd approach has an important caveat documented in its manual. A set of syscalls is implicitly whitelisted and does not need to be listed explicitly. This includes execve, exit, exit_group, getrlimit, rt_sigreturn, sigreturn, and the syscalls for querying time and sleeping.

This is a side effect of the injection mechanism. Since a seccomp policy applies to the current process and its children, systemd must fork itself, apply the seccomp filter in the child, and then execve the target application. The execve call itself must always be allowed, else systemd cannot launch any service. This implicit allowance creates a security gap. The execve syscall is a common target for exploitation—if an attacker compromises an application, they will likely attempt to use it to execute a shell or another binary. But with the systemd approach, you cannot prohibit execve even if the application's core logic never needs it.

The Cloudflare Sandbox Toolkit

To address the limitations of the systemd approach, Cloudflare developed its own standalone toolkit, available on GitHub as cloudflare/sandbox. It consists of a shared library (libsandbox.so) for dynamically linked applications and an executable (sandboxify) for statically linked ones. This allows any syscall to be blocked externally, including those implicitly whitelisted by systemd.

Sandboxing Dynamically Linked Executables

For dynamically linked executables, the toolkit leverages the LD_PRELOAD environment variable. The libsandbox.so library contains an initialization routine that runs before the application's main logic. The process works as follows:

  1. LD_PRELOAD instructs the dynamic loader to load libsandbox.so at process startup.
  2. The runtime executes the library's initialization routine before most of the application code.
  3. This routine reads a policy from specially defined environment variables and applies the seccomp filter.
  4. By the time the application's main() function begins, the policy is already enforced.

Using the same myos tool, which we first confirm is dynamically linked:

$ ldd ./myos
	linux-vdso.so.1 (0x00007ffd8e1e3000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f339ddfb000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f339dfcf000)

We can block the uname syscall:

$ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libsandbox.so SECCOMP_SYSCALL_DENY=uname ./myos
adding uname to the process seccomp filter
Bad system call

The filter is applied without code changes, and unlike systemd, this method has no restrictions on which syscalls can be blocked. There is a caveat: LD_PRELOAD must be set correctly each time, or the process will run unprotected. This risk is mitigated by permanently embedding the library into the target binary:

$ patchelf --add-needed /usr/lib/x86_64-linux-gnu/libsandbox.so ./myos
$ ldd ./myos
	linux-vdso.so.1 (0x00007fff835ae000)
	/usr/lib/x86_64-linux-gnu/libsandbox.so (0x00007fc4f55f2000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc4f5425000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fc4f5647000)

After patching the compiled binary, the policy is configured via environment variables as before, without the need for LD_PRELOAD:

$ ./myos
My OS is Linux!
$ SECCOMP_SYSCALL_DENY=uname ./myos
adding uname to the process seccomp filter
Bad system call

Sandboxing Statically Linked Executables

The LD_PRELOAD method fails with statically linked executables, which don't use a dynamic loader. For these cases, the sandboxify launcher acts as a supervisor, injecting seccomp rules into the target process before it runs—similar in principle to how systemd does it, but with no implicit whitelist:

$ sandboxify ./myos
My OS is Linux!
$ SECCOMP_SYSCALL_DENY=uname sandboxify ./myos
adding uname to the process seccomp filter

In this case, the process is started by the launcher rather than directly by the shell, so no "Bad system call" message is printed. Unlike systemd, this launcher can prohibit even the execve syscall:

$ sandboxify /bin/bash -c 'echo I will try to execve something...; exec /usr/bin/echo Doing arbitrary code execution!!!'
I will try to execve something...
Doing arbitrary code execution!!!
SECCOMP_SYSCALL_DENY=execve sandboxify /bin/bash -c 'echo I will try to execve something...; exec /usr/bin/echo Doing arbitrary code execution!!!'
adding execve to the process seccomp filter
I will try to execve something...

Choosing Between sandboxify and libsandbox.so

While sandboxify works with dynamically linked executables too, the choice of tool significantly impacts an "allowlist" policy—where only explicitly named syscalls are permitted and everything else is blocked. This is the preferred security model over a "denylist."

Consider the toy application compiled as a dynamically linked binary. With libsandbox.so, it requires a minimum of four syscalls to function: exit_group:fstat:uname:write. Removing any one causes the process to terminate with a "Bad system call" error. Applying the same allowlist with the sandboxify launcher fails.

The reason is the stage at which each tool enforces the filter. A process has two stages: "runtime init" and "main logic." The runtime init stage runs code auto-generated by the compiler before the developer's main() function is called. This stage uses many syscalls that are not needed later. libsandbox.so enforces its policy after the runtime init stage completes, so its allowlist only needs to include syscalls used during the main logic phase. In contrast, sandboxify applies the policy before runtime init begins, forcing the allowlist to include all the syscalls required during that early stage as well. This results in a much broader allowlist for the same application.

For the toy example, allowing the application to run under sandboxify requires 13 different syscalls compared to just 4 with libsandbox.so.

Cloudflare's toolkit provides a route to sandboxing without source code access, addressing the missing link between developer capabilities and operator incentives. By using libsandbox.so for dynamic applications and sandboxify for static ones, operators can build tighter, more secure seccomp profiles than what is possible with standard systemd directives alone.