scipio avatar

Learn Zig Series (#73) - seccomp and Sandboxing

scipio

Published: 08 Jun 2026 › Updated: 08 Jun 2026Learn Zig Series (#73) - seccomp and Sandboxing

Learn Zig Series (#73) - seccomp and Sandboxing

Learn Zig Series (#73) - seccomp and Sandboxing

zig.png

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):

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(&regs));
            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 proceed
  • SECCOMP_RET_KILL -- kill the process immediately (SIGKILL)
  • SECCOMP_RET_KILL_THREAD -- kill just the calling thread
  • SECCOMP_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 it
  • SECCOMP_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

  1. 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/ls under stdio+file_read (works) vs stdio-only (fails on openat). Print which syscall caused the violation.

  2. Write a seccomp audit logger using SECCOMP_RET_LOG mode 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.

  3. Implement a pledge-like API with narrowing -- the program starts with pledge_narrow(.{ .stdio = true, .rpath = true, .inet = true }) then later drops network with pledge_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_PRIVS is 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

scipio@scipio

Leave Learn Zig Series (#73) - seccomp and Sandboxing to:

Written by

Does it matter who's right, or who's left?

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