Learn Zig Series (#73) - seccomp and Sandboxing
Learn Zig Series (#73) - seccomp and Sandboxing
What will I learn
- What seccomp is and how it restricts which system calls a process can make at the kernel level;
- How to enable seccomp strict mode (read/write/exit/sigreturn only) from Zig using the prctl syscall;
- How seccomp-bpf filter mode works and how to build BPF filter programs that whitelist specific syscalls;
- How to construct a practical sandbox that blocks file creation and network access while allowing normal computation;
- How to test and verify that your sandbox actually works by triggering blocked syscalls and catching the failures;
- How to combine seccomp with other Linux isolation mechanisms (no_new_privs, rlimits, namespaces) for defense in depth;
- How OpenBSD's pledge/unveil model compares to seccomp and what a pledge-like wrapper looks like in Zig.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing (this post)
Learn Zig Series (#73) - seccomp and Sandboxing
Solutions to Episode 72 Exercises
Exercise 1: Syscall tracer using PTRACE_SYSCALL
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const PTRACE_TRACEME: u32 = 0;
const PTRACE_SYSCALL: u32 = 24;
const PTRACE_GETREGS: u32 = 12;
const PTRACE_DETACH: u32 = 17;
const UserRegs = extern struct {
r15: u64, r14: u64, r13: u64, r12: u64,
rbp: u64, rbx: u64, r11: u64, r10: u64,
r9: u64, r8: u64, rax: u64, rcx: u64,
rdx: u64, rsi: u64, rdi: u64, orig_rax: u64,
rip: u64, cs: u64, eflags: u64, rsp: u64,
ss: u64, fs_base: u64, gs_base: u64,
ds: u64, es: u64, fs: u64, gs: u64,
};
fn ptrace(request: u32, pid: i32, addr: usize, data: usize) isize {
return @bitCast(linux.syscall4(
.ptrace,
request,
@as(usize, @bitCast(@as(isize, pid))),
addr,
data,
));
}
const syscall_names = [_][]const u8{
"read", "write", "open", "close", "stat", "fstat", "lstat", "poll",
"lseek", "mmap", "mprotect", "munmap", "brk", "rt_sigaction",
"rt_sigprocmask", "rt_sigreturn", "ioctl", "pread64", "pwrite64",
"readv", "writev", "access", "pipe", "select", "sched_yield",
"mremap", "msync", "mincore", "madvise", "shmget", "shmat", "shmctl",
"dup", "dup2", "pause", "nanosleep", "getitimer", "alarm", "setitimer",
"getpid", "sendfile", "socket", "connect", "accept", "sendto",
"recvfrom", "sendmsg", "recvmsg", "shutdown", "bind", "listen",
"getsockname", "getpeername", "socketpair", "setsockopt", "getsockopt",
"clone", "fork", "vfork", "execve", "exit", "wait4", "kill", "uname",
};
fn syscallName(num: u64) []const u8 {
if (num < syscall_names.len) return syscall_names[num];
return "unknown";
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const pid = try posix.fork();
if (pid == 0) {
// child: request tracing, then exec /bin/ls
_ = ptrace(PTRACE_TRACEME, 0, 0, 0);
_ = linux.syscall2(.kill, linux.syscall0(.getpid), 19); // SIGSTOP
const argv = [_]?[*:0]const u8{ "/bin/ls", "-l", "/tmp", null };
const envp = [_]?[*:0]const u8{ null };
_ = linux.syscall3(.execve, @intFromPtr(argv[0].?), @intFromPtr(&argv), @intFromPtr(&envp));
posix.exit(1);
}
// parent: wait for child to stop, then trace
_ = posix.waitpid(pid, 0);
var counts = std.AutoHashMap(u64, u32).init(std.heap.page_allocator);
defer counts.deinit();
var entering = true;
var total: u32 = 0;
while (true) {
_ = ptrace(PTRACE_SYSCALL, pid, 0, 0);
const wr = posix.waitpid(pid, 0);
if (wr.status.signal()) |_| break;
if (wr.status.exit_status() != null) break;
if (entering) {
var regs: UserRegs = undefined;
_ = ptrace(PTRACE_GETREGS, pid, 0, @intFromPtr(®s));
const entry = try counts.getOrPut(regs.orig_rax);
if (!entry.found_existing) entry.value_ptr.* = 0;
entry.value_ptr.* += 1;
total += 1;
}
entering = !entering;
}
try stdout.print("\nSyscall summary ({d} total):\n", .{total});
var it = counts.iterator();
while (it.next()) |entry| {
try stdout.print(" {s} (#{d}): {d} calls\n", .{
syscallName(entry.key_ptr.*), entry.key_ptr.*, entry.value_ptr.*,
});
}
}
The key insight is that ptrace delivers two stops per syscall -- one on entry and one on exit. We toggle a boolean to distinguish them and only read registers on entry, where orig_rax contains the syscall number (not rax, which gets overwritten with the return value).
Exercise 2: Type-safe ioctl wrapper library for terminal operations
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const TIOCGWINSZ: u32 = 0x5413;
const TIOCSWINSZ: u32 = 0x5414;
const TCGETS: u32 = 0x5401;
const TCSETS: u32 = 0x5402;
const Winsize = extern struct { ws_row: u16, ws_col: u16, ws_xpixel: u16, ws_ypixel: u16 };
const Termios = extern struct {
c_iflag: u32, c_oflag: u32, c_cflag: u32, c_lflag: u32,
c_line: u8, c_cc: [19]u8, _pad: [3]u8, c_ispeed: u32, c_ospeed: u32,
};
const IoctlError = error{ NotATerminal, BadFileDescriptor, PermissionDenied, Unexpected };
fn doIoctl(fd: i32, req: u32, arg: usize) IoctlError!void {
const r: isize = @bitCast(linux.syscall3(
.ioctl, @as(usize, @bitCast(@as(isize, fd))), req, arg,
));
if (r < 0) {
const e: u32 = @intCast(@as(usize, @bitCast(-r)));
return switch (e) {
25 => error.NotATerminal, // ENOTTY
9 => error.BadFileDescriptor, // EBADF
1 => error.PermissionDenied, // EPERM
else => error.Unexpected,
};
}
}
pub fn getWinsize(fd: i32) IoctlError!Winsize {
var ws: Winsize = undefined;
try doIoctl(fd, TIOCGWINSZ, @intFromPtr(&ws));
return ws;
}
pub fn setWinsize(fd: i32, ws: Winsize) IoctlError!void {
try doIoctl(fd, TIOCSWINSZ, @intFromPtr(&ws));
}
pub fn getTermios(fd: i32) IoctlError!Termios {
var t: Termios = undefined;
try doIoctl(fd, TCGETS, @intFromPtr(&t));
return t;
}
pub fn setTermios(fd: i32, t: Termios) IoctlError!void {
try doIoctl(fd, TCSETS, @intFromPtr(&t));
}
pub fn readOneKeyRaw(fd: i32) !u8 {
const orig = try getTermios(fd);
var raw = orig;
raw.c_lflag &= ~@as(u32, 0x8 | 0x2); // disable ECHO | ICANON
raw.c_cc[6] = 1; // VMIN = 1
raw.c_cc[5] = 0; // VTIME = 0
try setTermios(fd, raw);
defer setTermios(fd, orig) catch {};
var buf: [1]u8 = undefined;
_ = try posix.read(fd, &buf);
return buf[0];
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const ws = getWinsize(1) catch |err| {
try stdout.print("Not a terminal: {s}\n", .{@errorName(err)});
return;
};
try stdout.print("Terminal: {d}x{d}\n", .{ ws.ws_col, ws.ws_row });
try stdout.print("Press any key: ", .{});
const key = try readOneKeyRaw(0);
try stdout.print("\nYou pressed: 0x{x} ('{c}')\n", .{ key, if (key >= 32 and key < 127) key else '.' });
}
The critical detail is restoring the original termios on exit -- even on error. The defer setTermios(fd, orig) catch {} ensures cleanup happens regardless of how the function exits. Without this, a crash leaves the terminal in raw mode (no echo, no line buffering) which is seriously annoying.
Exercise 3: memfd-based IPC channel with futex synchronization
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const SharedMsg = extern struct {
futex_word: u32, // 0=parent_writing, 1=child_turn, 2=done
sequence: u32,
timestamp: i64,
payload_len: u32,
_pad: u32,
payload: [240]u8,
};
const MFD_CLOEXEC: u32 = 0x0001;
const FUTEX_WAIT: u32 = 0;
const FUTEX_WAKE: u32 = 1;
fn futexWait(ptr: *u32, expected: u32) void {
_ = linux.syscall4(.futex, @intFromPtr(ptr), FUTEX_WAIT, expected, 0);
}
fn futexWake(ptr: *u32) void {
_ = linux.syscall3(.futex, @intFromPtr(ptr), FUTEX_WAKE, 1);
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// create memfd
const fd_result: isize = @bitCast(linux.syscall2(.memfd_create, @intFromPtr("ipc_channel"), MFD_CLOEXEC));
if (fd_result < 0) return error.MemfdFailed;
const memfd: i32 = @intCast(fd_result);
// size it to hold our struct
_ = linux.syscall2(.ftruncate, @as(usize, @bitCast(@as(isize, memfd))), @sizeOf(SharedMsg));
// mmap it
const map_result = linux.mmap(null, @sizeOf(SharedMsg),
linux.PROT.READ | linux.PROT.WRITE,
.{ .TYPE = .SHARED }, memfd, 0);
const shared: *SharedMsg = @ptrFromInt(map_result);
// parent writes initial message
shared.futex_word = 0;
shared.sequence = 1;
shared.timestamp = @intCast(std.time.timestamp());
const msg = "Hello from parent process!";
@memcpy(shared.payload[0..msg.len], msg);
shared.payload_len = msg.len;
@fence(.seq_cst);
@atomicStore(u32, &shared.futex_word, 1, .seq_cst); // signal child
futexWake(&shared.futex_word);
const pid = try posix.fork();
if (pid == 0) {
// child: wait for message
while (@atomicLoad(u32, &shared.futex_word, .seq_cst) != 1)
futexWait(&shared.futex_word, 0);
const payload = shared.payload[0..shared.payload_len];
std.debug.print("[child] Got seq={d}: \"{s}\"\n", .{ shared.sequence, payload });
// write response
shared.sequence += 1;
shared.timestamp = @intCast(std.time.timestamp());
const reply = "Acknowledged by child!";
@memcpy(shared.payload[0..reply.len], reply);
shared.payload_len = reply.len;
@fence(.seq_cst);
@atomicStore(u32, &shared.futex_word, 2, .seq_cst);
futexWake(&shared.futex_word);
posix.exit(0);
}
// parent: wait for child response
while (@atomicLoad(u32, &shared.futex_word, .seq_cst) != 2)
futexWait(&shared.futex_word, 1);
try stdout.print("[parent] Got seq={d}: \"{s}\"\n", .{
shared.sequence, shared.payload[0..shared.payload_len],
});
_ = posix.waitpid(pid, 0);
_ = linux.munmap(map_result, @sizeOf(SharedMsg));
posix.close(memfd);
}
The futex (fast userspace mutex) is the synchronization primitive -- it sleeps the thread in the kernel only when the word doesn't match the expected value. In the fast path (child hasn't blocked yet), the wake is a no-op. In the slow path (child sleeping), the kernel wakes exactly one waiter. The @fence(.seq_cst) and atomic operations ensure the payload writes are visible before the futex word changes.
Last episode we tore open the system call layer -- raw syscall instructions, register conventions, error codes, the whole ugly beautiful truth of how userspace talks to the kernel. We built type-safe wrappers around raw syscalls and even implemented getrandom() and memfd_create() from scratch.
But here's something that should bother you: if your program can call ANY of those ~450 syscalls at any time, then a single exploited vulnerability gives the attacker access to the entire kernel API. They can open network sockets, create files, spawn processes, load kernel modules -- whatever the process has permissions for. That's a LOT of attack surface.
What if you could say: "this process only needs read, write, mmap, and exit -- block everything else"? That's exactly what seccomp does, and it's one of the most powerful security mechanisms Linux offers. Docker uses it. Chrome uses it. Systemd uses it. Flatpak, Firejail, bubblewrap -- all seccomp under the hood. Today we're building our own seccomp filters from scratch in Zig ;-)
What seccomp is and why it matters
seccomp (secure computing mode) is a Linux kernel feature that restricts which system calls a process is allowed to make. It operates at the kernel level -- there's no userspace bypass. Once you install a seccomp filter, the kernel checks EVERY single syscall against your rules before executing it. A blocked syscall either kills the process (SECCOMP_RET_KILL) or returns an error (SECCOMP_RET_ERRNO).
The name comes from the original "strict" mode introduced in Linux 2.6.12 (2005), which allowed only read(), write(), _exit(), and sigreturn(). That's it -- four syscalls. Anything else and the kernel immediately kills your process with SIGKILL. Strict mode is still available and useful for things like processing untrusted data where you genuinely don't need the filesystem or network.
The more flexible seccomp-bpf (filter mode) was added in Linux 3.5 (2012). It lets you write a BPF (Berkeley Packet Filter) program that inspects each syscall's number and arguments, then decides what to do. BPF is the same technology used for network packet filtering -- it's a small register-based virtual machine that runs inside the kernel, verified to be safe before loading.
Let's start with strict mode since it's the simplest to understand:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const PR_SET_SECCOMP: u32 = 22;
const SECCOMP_MODE_STRICT: u32 = 1;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Before seccomp strict mode -- we can do anything.\n", .{});
try stdout.print("PID: {d}\n", .{linux.syscall0(.getpid)});
// fork a child to test strict mode (parent stays unrestricted)
const pid = try posix.fork();
if (pid == 0) {
// child: enable strict mode
const msg_before = "Child: about to enable strict mode...\n";
_ = linux.syscall3(.write, 1, @intFromPtr(msg_before.ptr), msg_before.len);
// PR_SET_SECCOMP with SECCOMP_MODE_STRICT
_ = linux.syscall5(.prctl, PR_SET_SECCOMP, SECCOMP_MODE_STRICT, 0, 0, 0);
// write still works (one of the 4 allowed syscalls)
const msg_after = "Child: strict mode active. write() still works!\n";
_ = linux.syscall3(.write, 1, @intFromPtr(msg_after.ptr), msg_after.len);
// read still works
// sigreturn still works
// _exit still works
// but getpid? KILLED.
// uncomment to see: _ = linux.syscall0(.getpid);
// use _exit (allowed), not exit_group (blocked!)
_ = linux.syscall1(.exit, 0);
unreachable;
}
// parent waits
const result = posix.waitpid(pid, 0);
const status = result.status;
if (status.signal()) |sig| {
try stdout.print("Child killed by signal {d} (SIGKILL=9)\n", .{@intFromEnum(sig)});
} else {
try stdout.print("Child exited normally with code {d}\n", .{status.exit_status().?});
}
try stdout.print("\nStrict mode allows ONLY: read, write, _exit, sigreturn\n", .{});
try stdout.print("Everything else = instant SIGKILL (no cleanup, no handler)\n", .{});
}
Notice the subtlety: we must use _exit (syscall 60), NOT exit_group (syscall 231). The posix.exit() function calls exit_group, which is NOT in the strict mode whitelist. In strict mode, even your cleanup code can kill you if it calls the wrong syscall. That's the whole point -- the filter is absolutely merciless.
Building BPF filter programs for seccomp
Strict mode is too restrictive for most real programs. You usually need more than four syscalls but definitely less than 450. That's where seccomp-bpf comes in. You write a BPF program (a small bytecode program) that the kernel executes on every syscall entry. The program inspects the syscall number and arguments, then returns an action:
SECCOMP_RET_ALLOW-- let the syscall proceedSECCOMP_RET_KILL-- kill the process immediately (SIGKILL)SECCOMP_RET_KILL_THREAD-- kill just the calling threadSECCOMP_RET_ERRNO-- make the syscall return an error (you choose which errno)SECCOMP_RET_TRAP-- send SIGSYS to the process (useful for logging)SECCOMP_RET_LOG-- allow but log itSECCOMP_RET_TRACE-- notify a ptrace tracer
The BPF instruction format is simple -- 8 bytes per instruction with an opcode, jump targets, and a value field:
const std = @import("std");
const linux = std.os.linux;
// BPF instruction format (classic BPF, not eBPF)
const BpfInsn = extern struct {
code: u16, // opcode
jt: u8, // jump if true
jf: u8, // jump if false
k: u32, // constant / offset
};
// BPF opcodes we need
const BPF_LD: u16 = 0x00;
const BPF_W: u16 = 0x00;
const BPF_ABS: u16 = 0x20;
const BPF_JMP: u16 = 0x05;
const BPF_JEQ: u16 = 0x10;
const BPF_RET: u16 = 0x06;
const BPF_K: u16 = 0x00;
// seccomp data offsets (the structure the BPF program inspects)
const SECCOMP_DATA_NR: u32 = 0; // syscall number
const SECCOMP_DATA_ARCH: u32 = 4; // architecture
// seccomp return values
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000; // OR with errno value
const SECCOMP_RET_LOG: u32 = 0x7ffc0000;
// architecture audit value for x86-64
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
const PR_SET_NO_NEW_PRIVS: u32 = 38;
const PR_SET_SECCOMP: u32 = 22;
const SECCOMP_MODE_FILTER: u32 = 2;
const SockFprog = extern struct {
len: u16,
filter: [*]const BpfInsn,
};
fn bpfStmt(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJump(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// a simple filter: allow everything except socket() (syscall 41)
const filter = [_]BpfInsn{
// [0] load architecture
bpfStmt(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_ARCH),
// [1] verify x86-64 (skip 1 if match, kill if not)
bpfJump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
// [2] wrong arch -> kill
bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_KILL),
// [3] load syscall number
bpfStmt(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_NR),
// [4] is it socket() (41)? if yes skip 1, if no skip 0
bpfJump(BPF_JMP | BPF_JEQ | BPF_K, 41, 0, 1),
// [5] socket -> return EPERM
bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 1),
// [6] everything else -> allow
bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
try stdout.print("Building a BPF filter that blocks socket()...\n\n", .{});
try stdout.print("Filter has {d} instructions ({d} bytes)\n", .{
filter.len, filter.len * @sizeOf(BpfInsn),
});
// show the instructions
for (filter, 0..) |insn, i| {
try stdout.print(" [{d}] code=0x{x:0>4} jt={d} jf={d} k=0x{x:0>8}\n", .{
i, insn.code, insn.jt, insn.jf, insn.k,
});
}
// we need no_new_privs before installing filters
_ = linux.syscall5(.prctl, PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
const prog = SockFprog{
.len = filter.len,
.filter = &filter,
};
// install the filter
const result: isize = @bitCast(linux.syscall3(
.seccomp,
1, // SECCOMP_SET_MODE_FILTER
0, // flags
@intFromPtr(&prog),
));
if (result < 0) {
// fallback to prctl method (older kernels)
_ = linux.syscall5(.prctl, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, @intFromPtr(&prog), 0, 0);
}
try stdout.print("\nFilter installed! Testing:\n", .{});
// getpid still works
const pid = linux.syscall0(.getpid);
try stdout.print(" getpid() = {d} (allowed)\n", .{pid});
// write still works (you're reading this)
try stdout.print(" write() works (allowed)\n", .{});
// socket() should fail with EPERM
const sock_result = linux.syscall3(.socket, 2, 1, 0); // AF_INET, SOCK_STREAM
const sock_signed: isize = @bitCast(sock_result);
if (sock_signed < 0) {
const errno: u32 = @intCast(@as(usize, @bitCast(-sock_signed)));
try stdout.print(" socket() = -1, errno={d} (EPERM=1) -- BLOCKED!\n", .{errno});
} else {
try stdout.print(" socket() = {d} -- filter didn't work?!\n", .{sock_signed});
}
}
The BPF program structure is worth understanding. Each instruction has a 16-bit opcode, two 8-bit jump offsets (relative, for true/false branches), and a 32-bit immediate value. The program operates on a single accumulator register. BPF_LD | BPF_W | BPF_ABS loads a 32-bit word from the seccomp data structure at the given offset. BPF_JMP | BPF_JEQ | BPF_K compares the accumulator to a constant and jumps. BPF_RET | BPF_K returns an action.
PR_SET_NO_NEW_PRIVS before installing the filter is MANDATORY. Without it, an unprivileged process could install a filter blocking execve, then exec a setuid binary -- elevated privileges but blocked syscalls, a confused deputy attack. no_new_privs prevents privilege escalation through exec, making it safe to let unprivileged processes install filters.
Building a practical sandbox
Now let's put it all together and build something genuinely useful -- a sandbox that restricts a child process to a minimal set of syscalls. The approach: whitelist the syscalls needed for basic computation and I/O, block everything related to networking, process creation, and filesystem modification:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const BpfInsn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const SockFprog = extern struct { len: u16, filter: [*]const BpfInsn };
const BPF_LD: u16 = 0x00;
const BPF_W: u16 = 0x00;
const BPF_ABS: u16 = 0x20;
const BPF_JMP: u16 = 0x05;
const BPF_JEQ: u16 = 0x10;
const BPF_RET: u16 = 0x06;
const BPF_K: u16 = 0x00;
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000;
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
const PR_SET_NO_NEW_PRIVS: u32 = 38;
fn bpfStmt(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJump(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
fn buildSandboxFilter(buf: []BpfInsn) u16 {
// whitelist approach: default KILL, allow specific syscalls
const allowed_syscalls = [_]u32{
0, // read
1, // write
3, // close
9, // mmap
10, // mprotect
11, // munmap
12, // brk
13, // rt_sigaction
14, // rt_sigprocmask
15, // rt_sigreturn
16, // ioctl (needed for terminal)
21, // access
35, // nanosleep
39, // getpid
60, // exit
63, // uname
79, // getcwd
96, // gettimeofday
102, // getuid
104, // getgid
110, // getppid
158, // arch_prctl
218, // set_tid_address
228, // clock_gettime
231, // exit_group
257, // openat (read-only -- we check flags later)
262, // newfstatat
302, // prlimit64
318, // getrandom
334, // rseq
};
var i: u16 = 0;
// load arch, verify x86-64
buf[i] = bpfStmt(BPF_LD | BPF_W | BPF_ABS, 4);
i += 1;
buf[i] = bpfJump(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0);
i += 1;
buf[i] = bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_KILL);
i += 1;
// load syscall number
buf[i] = bpfStmt(BPF_LD | BPF_W | BPF_ABS, 0);
i += 1;
// for each allowed syscall: jump to ALLOW if match
const n_allowed: u8 = @intCast(allowed_syscalls.len);
for (allowed_syscalls, 0..) |sc, idx| {
const remaining: u8 = n_allowed - @as(u8, @intCast(idx)) - 1;
buf[i] = bpfJump(BPF_JMP | BPF_JEQ | BPF_K, sc, remaining, 0);
i += 1;
}
// default: kill (not in whitelist)
buf[i] = bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 1); // EPERM
i += 1;
// allow
buf[i] = bpfStmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
i += 1;
return i;
}
fn installSandbox() !void {
var filter_buf: [128]BpfInsn = undefined;
const len = buildSandboxFilter(&filter_buf);
_ = linux.syscall5(.prctl, PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
const prog = SockFprog{ .len = len, .filter = &filter_buf };
const result: isize = @bitCast(linux.syscall3(
.seccomp, 1, 0, @intFromPtr(&prog),
));
if (result < 0) {
const errno: u32 = @intCast(@as(usize, @bitCast(-result)));
std.debug.print("seccomp install failed, errno={d}\n", .{errno});
return error.SeccompFailed;
}
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Sandbox demo: forking a restricted child\n\n", .{});
const pid = try posix.fork();
if (pid == 0) {
installSandbox() catch {
std.debug.print("Failed to install sandbox\n", .{});
posix.exit(1);
};
// test allowed operations
const w = std.io.getStdOut().writer();
w.print(" [sandbox] write works\n", .{}) catch {};
w.print(" [sandbox] getpid = {d}\n", .{linux.syscall0(.getpid)}) catch {};
// test blocked: socket
const sock: isize = @bitCast(linux.syscall3(.socket, 2, 1, 0));
w.print(" [sandbox] socket() = {d} (blocked)\n", .{sock}) catch {};
// test blocked: fork
const fork_r: isize = @bitCast(linux.syscall0(.fork));
w.print(" [sandbox] fork() = {d} (blocked)\n", .{fork_r}) catch {};
// test blocked: execve
const exec_r: isize = @bitCast(linux.syscall3(
.execve, @intFromPtr("/bin/ls".ptr), 0, 0,
));
w.print(" [sandbox] execve() = {d} (blocked)\n", .{exec_r}) catch {};
// test allowed: read a file (openat is whitelisted)
const fd = posix.open("/etc/hostname", .{ .ACCMODE = .RDONLY }, 0) catch |err| {
w.print(" [sandbox] open /etc/hostname: {s}\n", .{@errorName(err)}) catch {};
posix.exit(0);
};
var buf: [128]u8 = undefined;
const n = posix.read(fd, &buf) catch 0;
w.print(" [sandbox] read /etc/hostname: {s}", .{buf[0..n]}) catch {};
posix.close(fd);
posix.exit(0);
}
const result = posix.waitpid(pid, 0);
if (result.status.signal()) |sig| {
try stdout.print("\nChild killed by signal {d}\n", .{@intFromEnum(sig)});
} else {
try stdout.print("\nChild exited with code {d}\n", .{result.status.exit_status().?});
}
}
The whitelist approach is the correct default for security: deny everything, then explicitly allow what you need. A blacklist approach (allow everything, block specific things) is tempting because it's easier, but it fails against new syscalls added in future kernel versions -- you'd have to keep updating your blacklist. With a whitelist, new syscalls are blocked automatically.
The jump chain works by computing relative offsets. Each BPF_JEQ instruction compares the accumulator (loaded with the syscall number) to one allowed value. If it matches, it jumps forward to the SECCOMP_RET_ALLOW instruction. If no match, execution falls through to the default SECCOMP_RET_ERRNO that returns EPERM. It's not the most effecient encoding (linear scan), but it's correct and easy to reason about. For large whitelists you could use a binary search tree in BPF, but for 30 syscalls the linear scan is fine.
Testing your sandbox: verifying restrictions actually work
A sandbox you don't test is worse than no sandbox at all -- it gives you false confidence. Here's a systematic approach to verifying that every blocked syscall is actually blocked:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const BpfInsn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const SockFprog = extern struct { len: u16, filter: [*]const BpfInsn };
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
fn bpfS(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJ(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
const TestResult = struct {
name: []const u8,
expected_blocked: bool,
actual_blocked: bool,
};
fn testSyscall(comptime name: []const u8, num: u32) TestResult {
// try the syscall with dummy args (most will fail, we just care if seccomp blocks it)
const result: isize = @bitCast(switch (num) {
41 => linux.syscall3(.socket, 2, 1, 0), // AF_INET, SOCK_STREAM
57 => linux.syscall0(.fork),
59 => linux.syscall3(.execve, @intFromPtr("/nonexistent".ptr), 0, 0),
56 => linux.syscall5(.clone, 0, 0, 0, 0, 0),
62 => linux.syscall2(.kill, 0, 0), // kill(0, 0) = check permissions only
39 => linux.syscall0(.getpid), // should succeed
1 => blk: { break :blk @as(usize, 0); }, // write -- tested separately
else => @as(usize, @bitCast(@as(isize, -1))),
});
// EPERM (errno 1) means seccomp blocked it
const blocked = (result == @as(usize, @bitCast(@as(isize, -1))));
_ = blocked;
// actually check if errno is EPERM
const is_eperm = if (result < 0) blk: {
const e: u32 = @intCast(@as(usize, @bitCast(@as(isize, 0) - result)));
break :blk (e == 1);
} else false;
return .{
.name = name,
.expected_blocked = true,
.actual_blocked = is_eperm,
};
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Seccomp sandbox verification suite\n", .{});
try stdout.print("===================================\n\n", .{});
// install a minimal filter: only allow read, write, exit, exit_group,
// sigreturn, mmap, brk, close, clock_gettime, getrandom, openat, newfstatat,
// getpid, ioctl, mprotect, munmap, rt_sigaction, rt_sigprocmask, prlimit64
const allowed = [_]u32{ 0, 1, 3, 9, 10, 11, 12, 13, 14, 15, 16, 39, 60, 158, 228, 231, 257, 262, 302, 318, 334 };
var filter: [64]BpfInsn = undefined;
var fi: u16 = 0;
filter[fi] = bpfS(0x20, 4); fi += 1; // load arch
filter[fi] = bpfJ(0x15, AUDIT_ARCH_X86_64, 1, 0); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_KILL); fi += 1;
filter[fi] = bpfS(0x20, 0); fi += 1; // load syscall nr
const alen: u8 = @intCast(allowed.len);
for (allowed, 0..) |sc, idx| {
const rem: u8 = alen - @as(u8, @intCast(idx)) - 1;
filter[fi] = bpfJ(0x15, sc, rem, 0); fi += 1;
}
filter[fi] = bpfS(0x06, SECCOMP_RET_ERRNO | 1); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_ALLOW); fi += 1;
_ = linux.syscall5(.prctl, 38, 1, 0, 0, 0); // no_new_privs
const prog = SockFprog{ .len = fi, .filter = &filter };
_ = linux.syscall3(.seccomp, 1, 0, @intFromPtr(&prog));
try stdout.print("Filter installed ({d} instructions, {d} whitelisted syscalls)\n\n", .{ fi, allowed.len });
// test blocked syscalls
const tests = [_]struct { name: []const u8, nr: u32 }{
.{ .name = "socket", .nr = 41 },
.{ .name = "fork", .nr = 57 },
.{ .name = "execve", .nr = 59 },
.{ .name = "kill", .nr = 62 },
.{ .name = "clone", .nr = 56 },
};
var pass: u32 = 0;
var fail: u32 = 0;
for (tests) |t| {
const result: isize = @bitCast(switch (t.nr) {
41 => linux.syscall3(.socket, 2, 1, 0),
57 => linux.syscall0(.fork),
59 => linux.syscall3(.execve, @intFromPtr("/x".ptr), 0, 0),
62 => linux.syscall2(.kill, 99999, 0),
56 => linux.syscall5(.clone, 0, 0, 0, 0, 0),
else => @as(usize, @bitCast(@as(isize, -1))),
});
const is_eperm = if (result < 0) blk: {
const e: u32 = @intCast(@as(usize, @bitCast(-result)));
break :blk (e == 1);
} else false;
if (is_eperm) {
try stdout.print(" PASS: {s}() blocked (EPERM)\n", .{t.name});
pass += 1;
} else {
try stdout.print(" FAIL: {s}() returned {d} -- NOT blocked!\n", .{ t.name, result });
fail += 1;
}
}
// test allowed syscalls
const pid_val = linux.syscall0(.getpid);
try stdout.print(" PASS: getpid() = {d} (allowed)\n", .{pid_val});
pass += 1;
try stdout.print("\nResults: {d} passed, {d} failed\n", .{ pass, fail });
}
This is what matters in practice. A lot of people install seccomp filters and never verify them. The attacker will verify them for you, and that's a much worse time to discover you forgot to block execve. Run your tests in CI, run them on every kernel upgrade, run them whenever you change the filter.
Defense in depth: layering seccomp with other isolation
Seccomp is powerful but it only controls WHICH syscalls can be made. It doesn't restrict file paths, network addresses, or memory layouts. For a proper sandbox, you want multiple layers. As we covered in episode 71, rlimits control resource consumption. Combined with seccomp and no_new_privs, you get a process that can't escape its syscall jail, can't gain privileges, and can't consume unlimited resources:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const BpfInsn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const SockFprog = extern struct { len: u16, filter: [*]const BpfInsn };
const Rlimit = extern struct { cur: u64, max: u64 };
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
fn bpfS(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJ(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
const SandboxConfig = struct {
max_memory_mb: u64 = 128,
max_files: u64 = 16,
max_cpu_seconds: u64 = 10,
allowed_syscalls: []const u32,
};
fn applySandbox(config: SandboxConfig) !void {
// Layer 1: no_new_privs (prevents privilege escalation via exec)
_ = linux.syscall5(.prctl, 38, 1, 0, 0, 0);
// Layer 2: resource limits
const mem_limit = config.max_memory_mb * 1024 * 1024;
const rlim_as = Rlimit{ .cur = mem_limit, .max = mem_limit };
_ = linux.syscall2(.setrlimit, 9, @intFromPtr(&rlim_as)); // RLIMIT_AS
const rlim_nofile = Rlimit{ .cur = config.max_files, .max = config.max_files };
_ = linux.syscall2(.setrlimit, 7, @intFromPtr(&rlim_nofile));
const rlim_cpu = Rlimit{ .cur = config.max_cpu_seconds, .max = config.max_cpu_seconds };
_ = linux.syscall2(.setrlimit, 0, @intFromPtr(&rlim_cpu));
const rlim_nproc = Rlimit{ .cur = 1, .max = 1 }; // can't fork
_ = linux.syscall2(.setrlimit, 6, @intFromPtr(&rlim_nproc));
const rlim_core = Rlimit{ .cur = 0, .max = 0 }; // no core dumps (info leak)
_ = linux.syscall2(.setrlimit, 4, @intFromPtr(&rlim_core));
// Layer 3: seccomp-bpf
var filter: [128]BpfInsn = undefined;
var fi: u16 = 0;
filter[fi] = bpfS(0x20, 4); fi += 1;
filter[fi] = bpfJ(0x15, AUDIT_ARCH_X86_64, 1, 0); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_KILL); fi += 1;
filter[fi] = bpfS(0x20, 0); fi += 1;
const alen: u8 = @intCast(config.allowed_syscalls.len);
for (config.allowed_syscalls, 0..) |sc, idx| {
const rem: u8 = alen - @as(u8, @intCast(idx)) - 1;
filter[fi] = bpfJ(0x15, sc, rem, 0); fi += 1;
}
filter[fi] = bpfS(0x06, SECCOMP_RET_ERRNO | 1); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_ALLOW); fi += 1;
const prog = SockFprog{ .len = fi, .filter = &filter };
const result: isize = @bitCast(linux.syscall3(.seccomp, 1, 0, @intFromPtr(&prog)));
if (result < 0) return error.SeccompFailed;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const compute_only = [_]u32{
0, 1, 3, 9, 10, 11, 12, 13, 14, 15, 16, // basics
39, 60, 96, 102, 104, 158, 228, 231, // info + exit
257, 262, 302, 318, 334, // file access (read), getrandom
};
try stdout.print("Multi-layer sandbox demo\n", .{});
try stdout.print("========================\n\n", .{});
const pid = try posix.fork();
if (pid == 0) {
applySandbox(.{
.max_memory_mb = 64,
.max_files = 8,
.max_cpu_seconds = 5,
.allowed_syscalls = &compute_only,
}) catch {
std.debug.print("Sandbox setup failed\n", .{});
posix.exit(1);
};
const w = std.io.getStdOut().writer();
w.print(" Layer 1: no_new_privs = active\n", .{}) catch {};
w.print(" Layer 2: rlimits = 64MB mem, 8 files, 5s CPU, 1 proc\n", .{}) catch {};
w.print(" Layer 3: seccomp = {d} syscalls whitelisted\n\n", .{compute_only.len}) catch {};
// demonstrate each layer
// seccomp blocks network
const sock: isize = @bitCast(linux.syscall3(.socket, 2, 1, 0));
w.print(" socket() = {d} (seccomp: blocked)\n", .{sock}) catch {};
// rlimit blocks excess file opens
var fds: [16]i32 = undefined;
var opened: u32 = 0;
for (&fds) |*fd| {
fd.* = @intCast(@as(isize, @bitCast(linux.syscall3(
.openat,
@as(usize, @bitCast(@as(isize, linux.AT.FDCWD))),
@intFromPtr("/dev/null".ptr),
0,
))));
if (fd.* < 0) break;
opened += 1;
}
w.print(" opened {d}/16 files (rlimit: blocked excess)\n", .{opened}) catch {};
for (fds[0..opened]) |fd| posix.close(fd);
// seccomp blocks fork/exec
const fork_r: isize = @bitCast(linux.syscall0(.fork));
w.print(" fork() = {d} (seccomp: blocked)\n", .{fork_r}) catch {};
w.print("\n All three layers working together!\n", .{}) catch {};
posix.exit(0);
}
const result = posix.waitpid(pid, 0);
if (result.status.signal()) |sig| {
try stdout.print("\nChild killed by signal {d}\n", .{@intFromEnum(sig)});
} else {
try stdout.print("\nChild exited with code {d}\n", .{result.status.exit_status().?});
}
try stdout.print("\nDefense in depth: each layer catches what the others miss.\n", .{});
try stdout.print(" no_new_privs: prevents privilege escalation\n", .{});
try stdout.print(" rlimits: caps resource consumption\n", .{});
try stdout.print(" seccomp: restricts kernel API surface\n", .{});
}
The order matters: no_new_privs first (prerequisite for unprivileged seccomp), then rlimits (independent of seccomp), then seccomp last (once active, you can't call syscalls needed for rlimit setup). Each layer is independent -- even if one is bypassed, the others still restrict the process.
Comparing seccomp to OpenBSD's pledge/unveil
If you've worked with OpenBSD, you know about pledge and unveil -- two beautifully simple security mechanisms. pledge("stdio rpath", NULL) says "this process will only use stdio and read-only file access." unveil("/etc/hostname", "r") says "this process can only see /etc/hostname, and only for reading." Everything else is invisible.
Compared to seccomp, pledge/unveil are much more ergonomic. You don't write BPF bytecode -- you write human-readable permission strings. But they're OpenBSD-only. On Linux, the equivalent requires combining seccomp (for syscall filtering), Landlock (for filesystem access control, added in Linux 5.13), and namespaces (for network and PID isolation).
Here's what a pledge-like wrapper looks like in Zig, mapping pledge categories to seccomp whitelists:
const std = @import("std");
const linux = std.os.linux;
const BpfInsn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const SockFprog = extern struct { len: u16, filter: [*]const BpfInsn };
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
fn bpfS(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJ(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
const PledgeFlags = packed struct {
stdio: bool = false, // read, write, close, mmap, brk, etc.
rpath: bool = false, // read-only filesystem access
wpath: bool = false, // write filesystem access
cpath: bool = false, // create/delete filesystem entries
inet: bool = false, // network sockets
proc: bool = false, // fork, exec, wait
exec: bool = false, // execve only
_pad: u9 = 0,
};
const stdio_calls = [_]u32{ 0, 1, 3, 9, 10, 11, 12, 13, 14, 15, 16, 35, 39, 60, 96, 102, 104, 110, 158, 228, 231, 302, 318, 334 };
const rpath_calls = [_]u32{ 4, 5, 6, 21, 79, 257, 262 }; // stat, fstat, lstat, access, getcwd, openat, newfstatat
const wpath_calls = [_]u32{ 76, 82, 87, 90 }; // truncate, rename, link, chmod
const cpath_calls = [_]u32{ 83, 84, 85, 86, 88, 133, 258, 259 }; // mkdir, rmdir, creat, link, unlink, mkdirat, unlinkat
const inet_calls = [_]u32{ 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55 }; // socket..getsockopt
const proc_calls = [_]u32{ 56, 57, 58, 61, 62 }; // clone, fork, vfork, wait4, kill
const exec_calls = [_]u32{ 59 }; // execve
fn pledge(flags: PledgeFlags) !void {
var all_calls: [256]u32 = undefined;
var count: usize = 0;
// always include stdio (base set)
if (flags.stdio) {
for (stdio_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.rpath) {
for (rpath_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.wpath) {
for (wpath_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.cpath) {
for (cpath_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.inet) {
for (inet_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.proc) {
for (proc_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (flags.exec) {
for (exec_calls) |sc| { all_calls[count] = sc; count += 1; }
}
if (count == 0) return error.EmptyPledge;
// build BPF filter
var filter: [300]BpfInsn = undefined;
var fi: u16 = 0;
filter[fi] = bpfS(0x20, 4); fi += 1;
filter[fi] = bpfJ(0x15, AUDIT_ARCH_X86_64, 1, 0); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_KILL); fi += 1;
filter[fi] = bpfS(0x20, 0); fi += 1;
const alen: u8 = @intCast(count);
for (all_calls[0..count], 0..) |sc, idx| {
const rem: u8 = alen - @as(u8, @intCast(idx)) - 1;
filter[fi] = bpfJ(0x15, sc, rem, 0); fi += 1;
}
filter[fi] = bpfS(0x06, SECCOMP_RET_ERRNO | 1); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_ALLOW); fi += 1;
_ = linux.syscall5(.prctl, 38, 1, 0, 0, 0);
const prog = SockFprog{ .len = fi, .filter = &filter };
const result: isize = @bitCast(linux.syscall3(.seccomp, 1, 0, @intFromPtr(&prog)));
if (result < 0) return error.SeccompFailed;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Pledge-like API for Linux seccomp\n", .{});
try stdout.print("=================================\n\n", .{});
try stdout.print("OpenBSD: pledge(\"stdio rpath\", NULL);\n", .{});
try stdout.print("Our Zig: pledge(.{{ .stdio = true, .rpath = true }});\n\n", .{});
try pledge(.{ .stdio = true, .rpath = true });
try stdout.print("Pledged: stdio + rpath\n\n", .{});
// allowed: read a file
const fd = std.fs.cwd().openFile("/etc/hostname", .{}) catch |err| {
try stdout.print(" openFile: {s}\n", .{@errorName(err)});
return;
};
defer fd.close();
var buf: [128]u8 = undefined;
const n = fd.read(&buf) catch 0;
try stdout.print(" read /etc/hostname: {s}", .{buf[0..n]});
// blocked: create a file
_ = std.fs.cwd().createFile("/tmp/pledge_test", .{}) catch |err| {
try stdout.print(" createFile: {s} (blocked -- no wpath/cpath)\n", .{@errorName(err)});
};
// blocked: network
const sock: isize = @bitCast(linux.syscall3(.socket, 2, 1, 0));
try stdout.print(" socket(): {d} (blocked -- no inet)\n", .{sock});
try stdout.print("\nThe API is simpler than raw BPF, but the enforcement is identical.\n", .{});
}
This is a simplified version -- real pledge handles more categories and edge cases. But the core idea works: higher-level security APIs on top of raw BPF, no kernel modifications needed. Having said that, Landlock (filesystem access control similar to unveil, added in Linux 5.13) is gaining adoption, so the ecosystem is moving in the right direction.
A sandboxed file parser: putting it all together
Let's build something practical -- a file parser that can only read one specific file. This is a common pattern for processing untrusted input: you want to parse a file (maybe an image, a PDF, a config) but you don't trust the parser code to be bug-free. A sandbox ensures that even if the parser has a buffer overflow or other vulnerability, the attacker can't do anything useful with it:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const BpfInsn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const SockFprog = extern struct { len: u16, filter: [*]const BpfInsn };
const Rlimit = extern struct { cur: u64, max: u64 };
const SECCOMP_RET_ALLOW: u32 = 0x7fff0000;
const SECCOMP_RET_ERRNO: u32 = 0x00050000;
const SECCOMP_RET_KILL: u32 = 0x00000000;
const AUDIT_ARCH_X86_64: u32 = 0xc000003e;
fn bpfS(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn bpfJ(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
fn sandboxedParse(fd: i32) !void {
const w = std.io.getStdOut().writer();
// lock down BEFORE parsing
// rlimits: 32MB memory, no new files, 2 seconds CPU, no forks
const rl_as = Rlimit{ .cur = 32 * 1024 * 1024, .max = 32 * 1024 * 1024 };
_ = linux.syscall2(.setrlimit, 9, @intFromPtr(&rl_as));
const rl_nofile = Rlimit{ .cur = 4, .max = 4 }; // stdin/stdout/stderr + our file
_ = linux.syscall2(.setrlimit, 7, @intFromPtr(&rl_nofile));
const rl_cpu = Rlimit{ .cur = 2, .max = 2 };
_ = linux.syscall2(.setrlimit, 0, @intFromPtr(&rl_cpu));
const rl_nproc = Rlimit{ .cur = 1, .max = 1 };
_ = linux.syscall2(.setrlimit, 6, @intFromPtr(&rl_nproc));
// seccomp: only read, write, close, exit, brk, mmap/munmap/mprotect,
// rt_sigaction, rt_sigprocmask, rt_sigreturn, clock_gettime, exit_group
const allowed = [_]u32{ 0, 1, 3, 9, 10, 11, 12, 13, 14, 15, 60, 158, 228, 231 };
var filter: [64]BpfInsn = undefined;
var fi: u16 = 0;
filter[fi] = bpfS(0x20, 4); fi += 1;
filter[fi] = bpfJ(0x15, AUDIT_ARCH_X86_64, 1, 0); fi += 1;
filter[fi] = bpfS(0x06, SECCOMP_RET_KILL); fi += 1;
filter[fi] = bpfS(0x20, 0); fi += 1;
const alen: u8 = @intCast(allowed.len);
for (allowed, 0..) |sc, idx| {
const rem: u8 = alen - @as(u8, @intCast(idx)) - 1;
filter[fi] = bpfJ(0x15, sc, rem, 0); fi += 1;
}
filter[fi] = bpfS(0x06, SECCOMP_RET_KILL); fi += 1; // KILL, not ERRNO
filter[fi] = bpfS(0x06, SECCOMP_RET_ALLOW); fi += 1;
_ = linux.syscall5(.prctl, 38, 1, 0, 0, 0);
const prog = SockFprog{ .len = fi, .filter = &filter };
_ = linux.syscall3(.seccomp, 1, 0, @intFromPtr(&prog));
// NOW parse -- sandbox is active
// can't open files, can't make sockets, can't fork, can't exec
// can only read from fd, write to stdout, and exit
var buf: [4096]u8 = undefined;
var total_bytes: usize = 0;
var line_count: u32 = 0;
var max_line_len: u32 = 0;
var current_line_len: u32 = 0;
while (true) {
const n = posix.read(fd, &buf) catch break;
if (n == 0) break;
total_bytes += n;
for (buf[0..n]) |byte| {
if (byte == '\n') {
line_count += 1;
if (current_line_len > max_line_len) max_line_len = current_line_len;
current_line_len = 0;
} else {
current_line_len += 1;
}
}
}
if (current_line_len > 0) line_count += 1; // last line without newline
try w.print(" Parse results:\n", .{});
try w.print(" Total bytes: {d}\n", .{total_bytes});
try w.print(" Lines: {d}\n", .{line_count});
try w.print(" Max line length: {d}\n", .{max_line_len});
posix.close(fd);
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var args = std.process.args();
_ = args.next();
const target_path = args.next() orelse "/etc/passwd";
try stdout.print("Sandboxed file parser\n", .{});
try stdout.print("Target: {s}\n\n", .{target_path});
// open the file BEFORE forking (parent keeps full access)
const fd = posix.open(target_path, .{ .ACCMODE = .RDONLY }, 0) catch |err| {
try stdout.print("Cannot open {s}: {s}\n", .{ target_path, @errorName(err) });
return;
};
const pid = try posix.fork();
if (pid == 0) {
// child: parse the already-open fd inside a sandbox
sandboxedParse(fd) catch |err| {
std.debug.print("Parse error: {s}\n", .{@errorName(err)});
};
posix.exit(0);
}
posix.close(fd); // parent doesn't need it
const result = posix.waitpid(pid, 0);
if (result.status.signal()) |sig| {
try stdout.print("Parser killed by signal {d}", .{@intFromEnum(sig)});
if (@intFromEnum(sig) == 31) try stdout.print(" (SIGSYS -- seccomp violation!)");
if (@intFromEnum(sig) == 9) try stdout.print(" (SIGKILL -- seccomp strict kill)");
try stdout.print("\n", .{});
} else {
try stdout.print("Parser finished (code {d})\n", .{result.status.exit_status().?});
}
}
The critical design pattern here: open the file before entering the sandbox, then pass the file descriptor to the sandboxed code. The sandboxed process can't call openat (it's not in the whitelist), so it can't open any NEW files -- it can only read from the fd it already has. If a vulnerability in the parser gives an attacker code execution, they're trapped: no filesystem access, no network, no process spawning, 32MB memory cap, 2 second CPU limit. The worst they can do is write garbage to stdout.
This pattern is used by OpenSSH (privsep), Chromium (site isolation), and many other security-critical programs. The privileged parent opens resources and passes fds to unprivileged sandboxed children.
Exercises
Build a configurable sandbox launcher that reads a JSON profile specifying allowed syscall categories (stdio, file_read, file_write, network, process) and resource limits (max_memory, max_files, max_cpu). Build the seccomp-bpf filter, apply rlimits, then exec the target program. Test with
/bin/lsunder stdio+file_read (works) vs stdio-only (fails on openat). Print which syscall caused the violation.Write a seccomp audit logger using
SECCOMP_RET_LOGmode that profiles a target program's syscall usage, then outputs a minimal whitelist. This is the "learn then enforce" pattern -- observe what a program actually calls, then generate a strict filter. Test by profiling a program, then running it with the generated filter.Implement a pledge-like API with narrowing -- the program starts with
pledge_narrow(.{ .stdio = true, .rpath = true, .inet = true })then later drops network withpledge_narrow(.{ .stdio = true, .rpath = true }). Each call installs a new seccomp filter (they stack -- kernel runs all and takes the most restrictive). Verify by making a request, narrowing, then trying another request (should get EPERM).
Thanks for your time!
- seccomp restricts which system calls a process can make, enforced by the kernel -- no userspace bypass possible
- Strict mode allows only read, write, _exit, and sigreturn -- use it when processing untrusted data with zero need for filesystem or network
- seccomp-bpf lets you write BPF filter programs that inspect syscall numbers and arguments, returning allow/kill/errno/trap per call
PR_SET_NO_NEW_PRIVSis mandatory before installing unprivileged seccomp filters -- it prevents privilege escalation through exec- Whitelist over blacklist -- default-deny with explicit allows is the correct approach because new syscalls are automatically blocked
- The open-before-sandbox pattern (parent opens resources, child inherits fds, child installs seccomp) is used by OpenSSH, Chrome, and other security-critical software
- Combining seccomp with rlimits, no_new_privs, and eventually Landlock gives you defense in depth where each layer catches what the others miss
- OpenBSD's pledge/unveil are more ergonomic than seccomp-bpf, but you can build a similar high-level API on top of raw BPF filters in Zig
Leave Learn Zig Series (#73) - seccomp and Sandboxing to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#9) - Modules & Crates
- Learn AI Series (#139) - AI for Code
- Learn Zig Series (#119) - BFS and DFS
- Learn Rust Series (#8) - Traits & Generics
- Learn AI Series (#138) - Multimodal AI
- Learn Zig Series (#118) - Graph Representation
- Learn Rust Series (#7) - Collections
- Learn AI Series (#137) - Foundation Models
- Learn Zig Series (#117) - Binary Search Variations
- Learn Rust Series (#6) - Error Handling