seccomp-bpf in Go: Restricting Syscalls Without a Container
Docker's default seccomp profile blocks around 44 syscalls out of the roughly 450 the kernel exposes on x86_64, things like ptrace, mount and kexec_load that a container almost never needs and an attacker would love to have. It works precisely because seccomp-bpf is a per-process kernel feature, not a container feature. runc just happens to install a filter before it execs your binary. There is nothing stopping your Go program from installing its own filter, tighter than Docker's, without a container runtime anywhere in sight: a setuid helper, a CLI tool that parses untrusted archives, a server that shells out to ffmpeg on user-supplied files. Anywhere you'd like "and if this process gets compromised, it still can't call execve" to be a kernel-enforced fact rather than a hope.
What seccomp-bpf is actually filtering
Every syscall entry gives the kernel a small struct to inspect before it does anything else:
struct seccomp_data {
int nr; /* syscall number */
__u32 arch; /* AUDIT_ARCH_* value */
__u64 instruction_pointer;
__u64 args[6];
};
A seccomp-bpf filter is a classic BPF (cBPF) program, the same instruction set used for SO_ATTACH_FILTER socket filters and tcpdump expressions, run against that struct on every syscall entry. It reads a few fields, compares them, and returns an action: SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO (fail the call with a chosen errno), SECCOMP_RET_TRAP, SECCOMP_RET_LOG, or a kill. This is the mode almost everyone means by "seccomp" these days. There's also the older SECCOMP_MODE_STRICT, which hardcodes the allowed set to read, write, _exit and sigreturn and needs no BPF program at all, but it's too restrictive for anything beyond a compute sandbox that talks over a pre-opened pipe.
The syscall confusion trap
The arch field exists for an unpleasant reason. The x86_64 kernel still accepts the 32-bit syscall entry path (the old int 0x80 interface, or the compat mode), and the 32-bit syscall table has completely different numbering from the 64-bit one. If your filter only ever checks nr, an attacker with arbitrary code execution can trigger a 32-bit syscall entry and get a different function entirely for the same number you thought you'd blocked, or worse, land on a syscall you never even considered because it doesn't exist on 64-bit at all. Every correctly-written seccomp filter checks arch first and kills or denies immediately on anything other than the exact architecture it was written for:
const auditArchX86_64 = 0xc000003e // AUDIT_ARCH_X86_64
Chrome's sandbox and Docker's default profile both open with this check for the same reason. Skipping it is the single most common mistake in hand-rolled seccomp filters.
Assembling the filter with x/net/bpf
Here's the bit that's genuinely useful to know: you don't need cgo or libseccomp to build a cBPF program in Go. golang.org/x/net/bpf was written for capturing and filtering raw sockets, but classic BPF is classic BPF regardless of what attaches it, so the same assembler produces a valid seccomp program.
package main
import (
"golang.org/x/net/bpf"
"golang.org/x/sys/unix"
)
// build allows only the given syscall numbers on x86_64, killing the
// whole process on architecture mismatch or on anything else.
func buildFilter(allowed []uint32) ([]bpf.RawInstruction, error) {
insts := []bpf.Instruction{
bpf.LoadAbsolute{Off: 4, Size: 4}, // seccomp_data.arch
bpf.JumpIf{Cond: bpf.JumpEqual, Val: auditArchX86_64, SkipTrue: 1},
bpf.RetConstant{Val: uint32(unix.SECCOMP_RET_KILL_PROCESS)},
bpf.LoadAbsolute{Off: 0, Size: 4}, // seccomp_data.nr
}
for _, nr := range allowed {
insts = append(insts,
bpf.JumpIf{Cond: bpf.JumpEqual, Val: nr, SkipTrue: 0, SkipFalse: 1},
bpf.RetConstant{Val: uint32(unix.SECCOMP_RET_ALLOW)},
)
}
insts = append(insts, bpf.RetConstant{
Val: uint32(unix.SECCOMP_RET_ERRNO) | uint32(unix.EPERM),
})
return bpf.Assemble(insts)
}
That's a linear scan, one compare-and-return pair per allowed syscall, rather than a balanced jump tree. For a filter with a couple of dozen entries that's entirely fine; the kernel enforces a limit of 4096 BPF instructions per filter, and a linear scan over a hundred syscalls uses a fraction of that. libseccomp builds a proper decision tree when the list gets large, which is one of the reasons it exists (more on that below).
Installing it: no_new_privs and the TSYNC gotcha
bpf.RawInstruction has the exact same field layout as the kernel's struct sock_filter, which is why golang.org/x/sys/unix defines SockFilter with matching fields. Wiring it up:
import (
"fmt"
"runtime"
"unsafe"
"golang.org/x/sys/unix"
)
func install(prog []bpf.RawInstruction) error {
runtime.LockOSThread()
if err := unix.SetNoNewPrivs(); err != nil {
return fmt.Errorf("set no_new_privs: %w", err)
}
filters := make([]unix.SockFilter, len(prog))
for i, ins := range prog {
filters[i] = unix.SockFilter{Code: ins.Op, Jt: ins.Jt, Jf: ins.Jf, K: ins.K}
}
fprog := unix.SockFprog{Len: uint16(len(filters)), Filter: &filters[0]}
_, _, errno := unix.Syscall(unix.SYS_SECCOMP,
unix.SECCOMP_SET_MODE_FILTER,
unix.SECCOMP_FILTER_FLAG_TSYNC,
uintptr(unsafe.Pointer(&fprog)))
if errno != 0 {
return fmt.Errorf("seccomp: %w", errno)
}
return nil
}
PR_SET_NO_NEW_PRIVS has to be set first (or you need CAP_SYS_ADMIN), otherwise an unprivileged process could use seccomp plus a setuid binary to trick a privileged process into a filtered environment it didn't choose. runtime.LockOSThread is there so the goroutine can't hop OS threads between the two calls; both no_new_privs and the seccomp filter are thread-local kernel state, and if the Go scheduler moved your goroutine to a different M in between, the filter install would land on a thread that never set no_new_privs and fail with EACCES.
The real trap is the SECCOMP_FILTER_FLAG_TSYNC flag, and it's specifically a Go problem. Seccomp filters attach per-thread, and by default seccomp(2) only confines the calling thread. A C program that installs a filter early in main(), before spawning any other threads, doesn't need to think about this: every thread it creates afterwards inherits the filter via clone(). A Go program has almost certainly already spawned several OS threads by the time your code runs, for the scheduler, the GC, netpoller, and any goroutine currently blocked in a syscall, and it will keep creating more as needed. Install a filter without TSYNC and you've confined exactly one OS thread out of however many the runtime is using; every other one, including ones it creates five minutes later for a blocking syscall, runs completely unfiltered. TSYNC, added in Linux 3.17, tells the kernel to atomically apply the filter to every thread in the thread group at once, and fail the whole call if any sibling thread has an incompatible filter already installed. For a Go binary it is not optional.
One more Go-specific detail worth choosing deliberately: prefer SECCOMP_RET_KILL_PROCESS over the older SECCOMP_RET_KILL_THREAD (also spelled SECCOMP_RET_KILL). Killing only the offending thread made some sense in a single-threaded C program where "the thread" and "the process" were the same thing. In a Go binary they very much aren't; killing one OS thread out from under the runtime leaves the rest of the goroutines, and whatever they're halfway through doing, running in an already-corrupted process. KILL_PROCESS, available since Linux 4.14, takes the whole thing down cleanly instead.
Verifying it worked
The quickest sanity check is to allow a small, deliberately incomplete set, the read/write/exit family plus whatever your program's normal startup needs, then trigger something you left out. A disallowed syscall under KILL_PROCESS shows up as the process dying with signal SIGSYS, visible from a shell as exit status 159 (128 + 31) or via WIFSIGNALED/WTERMSIG if you're launching it from another Go process. If you used SECCOMP_RET_ERRNO instead, the blocked call just returns your chosen errno and the program carries on, which is friendlier for calls you're not sure about yet but weaker as a security boundary since a compromised process gets to see exactly which calls are blocked and probe around them.
When to reach for libseccomp instead
Hand-assembling BPF is fine for a filter with a static allowlist and no argument inspection. Where it gets tedious is anything past that: filtering on syscall arguments (some argument comparisons need 64-bit values split across two 32-bit loads, since cBPF only has a 32-bit accumulator), building profiles that need to work across x86_64, i386 compat, and x32 simultaneously, or generating an efficient decision tree instead of a linear scan for a few hundred entries. That's what libseccomp-golang is for, the cgo binding to the same C library Docker, containerd and runc use under the hood. The tradeoff is exactly what you'd expect: a cgo dependency, which complicates cross-compilation, in exchange for not hand-computing jump offsets. For a single-architecture Go binary with a fixed, known set of syscalls, which describes most services, the pure-Go route above has no real downside.
Either way, remember that seccomp only ever sees the syscall number, architecture and raw argument words, not paths, not file contents, not which file descriptor a number refers to. It can stop a compromised process from calling execve at all; it can't stop openat from opening a file it was always allowed to open. For that you want O_NOFOLLOW, openat2, capabilities, or a Landlock ruleset layered on top, not a heavier seccomp filter trying to do a job it wasn't built for.
The kernel's own writeup of the filter semantics is worth reading end to end before shipping one of these: seccomp_filter.html covers the return-value precedence rules (a thread can only ever move to a stricter filter, never a looser one) that matter as soon as you have more than one filter layer.