Defense-in-Depth Isn't Lego - by void *Huxley_Barbee
Huxley Barbee
SubscribeSign in
Defense-in-Depth Isn't Lego<br>A Case Study in Combining Linux Capabilities and Seccomp Filters
void *Huxley_Barbee<br>Aug 10, 2026
Share
Photo by Richard Heinen on Unsplash<br>Defense-in-depth is sound doctrine. Layered controls are supposed to mean that one control’s failure doesn’t leave you with nothing, and that’s a real, valuable property when the layers are actually built to hold each other up.<br>The part that’s wrong is an assumption that usually rides along with the doctrine: that security controls stack the way Lego bricks do: snap another one on, get another increment of protection, and the only real decision is how many you can afford to operate. Controls generally don’t stack that way. You have to verify that they actually layer well together before you can assume the combination holds. Here’s a concrete example of how that assumption goes wrong, using two kernel primitive security controls that are each, on their own, entirely sound.<br>A Quick Primer
Two Linux primitives are doing all the work in this post. If either is unfamiliar, these are worth reading before continuing; this post assumes working knowledge of both rather than re-teaching them:<br>Capabilities split root’s monolithic privilege into named, independently grantable units (CAP_NET_BIND_SERVICE, CAP_AUDIT_WRITE, and so on), so a process can hold exactly the privilege it needs instead of all of it or none of it. Primary reference: capabilities(7). A solid walkthrough of the different capability sets and how they’re assigned: Linux Audit’s Linux capabilities 101.
Seccomp restricts which syscalls a process is allowed to make at all, independent of what capabilities it holds. Primary reference: seccomp(2). For the history and design rationale in more depth: Michael Kerrisk’s A seccomp overview on LWN:
The specific capability used throughout the examples below is CAP_AUDIT_WRITE. It governs one narrow privilege: the ability to submit records into the kernel audit log via the audit netlink socket, the same subsystem auditd reads from. A process without it cannot write audit records at all, regardless of what else it can do; a process with only this capability can submit audit records and nothing more. It’s a deliberately small, single-purpose capability, which is what makes it a clean example here.<br>the demonstration isn’t complicated by the capability itself doing several unrelated things. The demo binary below calls audit_log_user_message() from libaudit to submit a real audit record, and every result is confirmed independently with ausearch, not just by trusting the demo binary’s own exit code.
Layer One: Capability Restriction Alone
Photo by Glen Carrie on Unsplash<br>Full source for launcher.c, audit_writer.c, and the Makefile used throughout this post is available on GitHub. The excerpts below are trimmed to the parts relevant to each step.<br>Two binaries. audit_writer is the target: it calls audit_log_user_message() and reports whether the call succeeded. launcher execs it, after first restricting itself down to exactly the privilege audit_writer needs and nothing more.<br>/* audit_writer.c */<br>#include<br>#include<br>#include
int main() {<br>int fd = audit_open();<br>int rc = audit_log_user_message(fd, AUDIT_USER,<br>"test message from exercise", NULL, NULL, NULL, 1);
if (rc
Setup, run as an ordinary unprivileged user:<br>gcc audit_writer.c -Wall -Werror -laudit -o audit_writer<br>sudo setcap 'cap_audit_write=ep' audit_writeraudit_writer itself never needs to be run as root or with sudo - only the one-time setcap grant does, exactly the point of file capabilities.<br>The launcher needs one capability of its own before it can even set up the restriction: dropping capabilities from its own bounding set via PR_CAPBSET_DROP requires CAP_SETPCAP in the caller’s effective set. A process can’t enforce least privilege on itself without first holding the one capability that grants the right to lower its own ceiling - and that capability has to be dropped last, once it’s no longer needed, or the “restricted” process would still be able to re-widen its own bounding set later.<br>void drop_bounding_caps(cap_value_t keep) {<br>for (int cap = 0; cap
Setup:<br>gcc launcher.c -Wall -Werror -lcap -lseccomp -o launcher<br>sudo setcap 'cap_setpcap=ep' launcherAt execve() for audit_writer, the kernel’s capability recompute formula for this launcher, unprivileged and with no file caps of its own on the target side of the equation, reduces to:<br>P'(permitted) = F(permitted) & P(bounding)
Since CAP_AUDIT_WRITE is in both, audit_writer comes up holding exactly the capability it needs. Running it, unprivileged, no seccomp yet:<br>$ ./launcher bounding_and_file no_seccomps<br>...<br>Audit succeeded: 1
ausearch confirms the record actually landed in the kernel audit log, not just that the demo binary claimed success:<br>$ sudo ausearch -m USER -ts recent<br>type=USER msg=audit(1785450082.947:31972): pid=219025 uid=1000...