Skip to content
FreeBSDDeep Dive July 11, 2026 5 min read

Capsicum Beyond the Basics: Building Capability-Mode Services From Scratch

A deeper look at FreeBSD's Capsicum framework — cap_rights, the process descriptor model, and how to structure a service around capability mode instead of bolting it on afterward.

Most sandboxing writeups treat Capsicum as a checkbox: call cap_enter(), watch a program keep working, done. That demo undersells what Capsicum actually is. It isn’t a syscall filter bolted onto an existing process model — it’s a redefinition of what a file descriptor is allowed to mean, applied consistently enough that “sandboxed” becomes a property of the process’s entire capability set, not a firewall around it.

The problem Capsicum actually solves

Traditional Unix access control is ambient: a process’s privileges live in its credentials (UID, GID, supplementary groups), and any code running as that process can exercise all of them against anything the filesystem namespace exposes. A bug in a JPEG parser has, by default, the same filesystem reach as the user account running it — not because the parser needs that reach, but because the process model has no cheaper way to express “less.”

Capsicum’s answer is capability-based security in the strict sense: a process in capability mode can only interact with the outside world through file descriptors it already holds, and each descriptor can carry its own restricted subset of operations. There’s no global namespace to escape into, because after cap_enter(), global namespaces (open() on a path, socket() to an arbitrary address, sysctl(), ptrace()) are simply gone as syscalls. What’s left is a fixed set of descriptors and whatever rights were attached to each one before entering capability mode.

cap_rights_t: rights live on the descriptor, not the process

The core primitive is cap_rights_t, a bitmask of fine-grained rights — CAP_READ, CAP_WRITE, CAP_SEEK, CAP_FSTAT, CAP_FCHMOD, dozens more — attached to an individual file descriptor via cap_rights_limit(). This is a one-way ratchet: rights can only be narrowed, never widened, and the narrowing is enforced by the kernel on every subsequent operation against that descriptor, not just at open time.

cap_rights_t rights;
cap_rights_init(&rights, CAP_READ, CAP_FSTAT, CAP_SEEK);
cap_rights_limit(config_fd, &rights);

After this call, config_fd can be read, seeked, and fstat’d — and nothing else. A write() against it fails with ENOTCAPABLE even though the descriptor was originally opened O_RDWR. This is the part that makes Capsicum different from a coarser sandbox: the restriction isn’t “this process can’t write files,” it’s “this specific descriptor, which the process already trusts for a specific purpose, can’t do more than that purpose requires” — and every other descriptor keeps whatever rights it was given independently.

Designing around it, not retrofitting it

The naive approach — write a normal program, then try to cap_enter() right before the “risky” part — usually fails immediately, because normal programs assume they can open() paths on demand. Capsicum-aware services are structured differently from the start:

  1. Do all path-based operations before entering capability mode. Open every file, directory, and socket the process will ever need, while the process still has full namespace access.
  2. Narrow rights on each descriptor immediately after opening it, using cap_rights_limit() — don’t wait until just before cap_enter().
  3. Enter capability mode as early as the design allows — once the previous two steps are done, not once the “dangerous” logic starts. The earlier the boundary, the smaller the code path that runs with the old, unrestricted rights.
  4. Route anything path-based that must happen later through openat() relative to an already-held, rights-limited directory descriptor — Capsicum permits openat() against a directory fd even in capability mode, as long as the resulting path stays within that directory’s subtree (enforced by the kernel, not by convention).

That fourth point is what lets Capsicum-sandboxed programs still do useful, dynamic file access — a web server can still serve arbitrary files under /var/www, for instance — without ever holding a capability that reaches outside the directory it was scoped to.

Process descriptors: capability mode for process management too

A subtler piece of Capsicum is pdfork(), a variant of fork() that returns a process descriptor — a file descriptor representing the child, rather than a bare PID. Once you have a process as a file descriptor, you can cap_rights_limit() it the same way as any other descriptor: strip the right to signal it, keep only the right to wait on its exit status, and pass that descriptor to a completely different, less-trusted process without ever exposing a signalable PID.

This matters because PIDs are a global, ambient namespace exactly like paths and sockets — kill(pid, SIGKILL) works against any process the caller has permission for, with no way to scope it to “only the child I actually forked.” pdfork() closes that gap the same way cap_rights_limit() closes it for files: the capability is the reference, and what you can do with it is exactly what rights it carries.

Where this actually gets used

sshd’s privilege-separated child processes, several BIND components, and parts of the base system’s DNS resolution path in modern FreeBSD releases run under Capsicum. The pattern is consistent across all of them: the pre-fork parent does every path lookup, DNS resolution, and configuration read the child could ever need, hands the child a small, fixed set of already-rights-limited descriptors, and only then calls cap_enter(). The child’s entire remaining lifetime runs with no ambient authority at all — if it’s compromised, there is no namespace left for the exploit to pivot into, only the exact set of descriptors it started with.

That’s the actual value proposition: not “sandboxing” as a bolt-on mitigation, but a process model where the question “what can this code reach if it’s fully compromised?” has a precise, kernel-enforced answer instead of “whatever the UID it’s running as can reach.”