Security Notes
Linux

Linux Sandboxing Primitives: Landlock, seccomp-BPF, LSM MAC, Namespaces and Workload Identity

25 min read 11 sections 7 model answers verified 2026-10 version 1 · updated 2026-10-24

Last verified2026-10 Also see: Sandbox isolation: gVisor vs Firecracker vs Kata (the next wall when these primitives are not enough) · Linux security fundamentals (seccomp and MAC basics) · Privilege escalation (container escapes) · Linux hardening · Build platform security (caches, BuildKit, SSH into jobs) · eBPF deep dive


Start here: the basics in plain words

A program running on Linux can only do things by asking the kernel. It asks through system calls (syscalls): "open this file", "send this packet", "start this process". Everything a sandbox does comes down to answering four questions about those requests:

What may you ask for?

seccomp-BPF filters the syscall number and its plain-number arguments. "You may never call mount()."

What may you touch?

Landlock, AppArmor and SELinux decide per object: "you may read /usr but not /etc/shadow". This is mandatory access control (MAC).

What can you see?

Namespaces give the process its own view of processes, mounts, network interfaces and user IDs. What it cannot see, it cannot name.

How much may you use?

Control groups (cgroups v2) meter and cap CPU, memory, process count and I/O so one job cannot starve the others.

None of these is "the sandbox". A real sandbox stacks them, and then, for truly untrusted code, adds a wall of a different kind (a user-space kernel like gVisor, or a virtual machine). This page is about the four kernel-level primitives, what each one really stops, where each one leaks, and how a CI runner layers them around a build it does not trust.

                      untrusted build step (shell, Starlark, Go test, npm install)
                                   │ syscalls
            ┌──────────────────────▼──────────────────────────┐
            │ seccomp-BPF   "is this syscall + these numbers allowed?"  │  ← first gate, cheapest
            ├─────────────────────────────────────────────────┤
            │ namespaces    "which files/pids/ifaces does it even see?"  │
            ├─────────────────────────────────────────────────┤
            │ Landlock / AppArmor / SELinux  "may it touch this object?" │  ← LSM hooks, per object
            ├─────────────────────────────────────────────────┤
            │ cgroups v2    "has it used up its CPU/memory/pids?"        │
            └──────────────────────┬──────────────────────────┘
                                   ▼
                          the one shared host kernel  ← every layer above lives inside it
Memory hook

"ask, see, touch, spend"seccomp limits what you can ask, namespaces limit what you can see, MAC limits what you can touch, cgroups limit what you can spend. If an interviewer asks "which primitive would stop X?", map X onto one of those four verbs first.


Why it matters

Interviewers use this topic to separate people who have run containers from people who understand them. A candidate who says "Docker isolates the process" has not thought about it. A candidate who says "Docker drops to 14 capabilities, installs a seccomp filter that denies about 44 syscalls, loads the docker-default AppArmor profile and puts the process in its own PID, mount, network, IPC and UTS namespaces, but by default not a user namespace, so root inside is root on the host kernel" has.

For an attacker the primitives are the map. A CI job that can run unshare -Ur gets CAP_SYS_ADMIN in a fresh user namespace, and with it reaches kernel code paths (filesystem mounting, network configuration) that ordinary users never touch. Most container escapes of the last five years were not "breaking out of a namespace"; they were kernel bugs reached through a syscall that the sandbox had left open. Which is why the defender's job is to decide, layer by layer, which doors stay open and which bugs that leaves reachable.


The taxonomy: four kinds of restriction

The primitives are easy to confuse because all four say "no" to a process. They differ in what they inspect and who may set them up.

PrimitiveInspectsGranularityWho can apply itTypical "no"
seccomp-BPFsyscall number + integer argsper thread, inheritedany process (after no_new_privs)EPERM, SIGSYS, or kill
Landlockthe object (file, directory, TCP port) at the LSM hookper thread, inherited, stackableany process (unprivileged)EACCES
AppArmor / SELinuxthe object, by path or by labelsystem-wide profiles/policyroot (and loaded at boot)EACCES, audit log line
Namespacesthe name the process usesper process treeroot, or anyone via user namespaces"no such file / pid / interface"
cgroups v2resource consumptionper group of processesthe delegating parentthrottle, OOM kill, EAGAIN on fork

Three distinctions do most of the work in interviews:

Syscall filtering vs object control

seccomp looks at the request before the kernel interprets it, so it can only see numbers. Landlock and the MAC systems run inside the kernel's own code path, after the path has been resolved to a real file, so they can judge the object. That difference is the whole reason Landlock exists (see the TOCTOU section).

Self-applied vs system-wide

seccomp and Landlock are applied by the process to itself and are inherited by its children; nothing outside needs to agree. AppArmor and SELinux are policies the administrator loads for the whole machine. A CI job can tighten itself with Landlock but cannot write an AppArmor profile at run time without root.

Visibility vs permission

a namespace does not forbid anything. It just gives the process a smaller world. A process in its own mount namespace cannot open /host/secret because, for it, that path does not exist. If it finds another route to the same inode (a leaked file descriptor, as in Leaky Vessels), the namespace never objected.

In practiceThe same kernel, three views of one process's restrictions. This is the lab VM (Ubuntu, kernel 6.8, arm64):

text
$ grep -E "Seccomp|NoNewPrivs|CapEff" /proc/self/status        # an ordinary shell
CapEff:  0000000000000000            ← unprivileged user: no capabilities
NoNewPrivs:  0
Seccomp:  0                          ← no syscall filter at all
Seccomp_filters:  0

$ sudo docker run --rm alpine grep -E "Seccomp|CapEff|CapBnd" /proc/self/status
CapEff:  00000000a80425fb            ← root in the container, but only 14 capabilities
CapBnd:  00000000a80425fb
Seccomp:  2                          ← mode 2 = BPF filter installed by the runtime
Seccomp_filters:  1

$ capsh --decode=00000000a80425fb
cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,
cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
                                     ← no cap_sys_admin, no cap_sys_ptrace, no cap_net_admin

$ sudo docker run --rm --cap-drop ALL --security-opt no-new-privileges alpine grep -E "CapEff|NoNewPrivs" /proc/self/status
CapEff:  0000000000000000            ← what a build step should look like
NoNewPrivs:  1                       ← set-uid binaries can no longer raise privilege
🎯

On the job. /proc/<pid>/status is the first thing to read when someone claims a workload is "sandboxed". Seccomp: 0 on a container means the runtime was started with seccomp=unconfined; CapEff ending in ...ffffffff means --privileged; NoNewPrivs: 0 means a set-uid binary in the image can still escalate. In a Kubernetes pod the same fields tell you whether the securityContext the manifest promised actually reached the process.


Namespaces and cgroups v2: a smaller world, with a meter

What a namespace really gives you

A namespace wraps one kind of global kernel resource and gives a process group its own copy of the names. Linux has eight: mount, PID, network, IPC, UTS (hostname), user, cgroup and time. The process still uses the same kernel code for everything; it just cannot name things outside its copy.

Mount

its own mount table. This is how a container "has" a different root filesystem: a pivot_root inside a private mount namespace.

PID

its own process numbering; PID 1 inside is some ordinary PID outside. It cannot send signals to processes it cannot number.

Network

its own interfaces, routing table, firewall rules and sockets. An empty network namespace has only lo: the cheapest possible "no network".

User

its own mapping of user IDs. This one is special: it is the only namespace an unprivileged user may create, and inside it the creator holds every capability, scoped to that namespace.

User namespaces: root that isn't root, mostly

The user namespace was designed to make rootless containers possible. The trade-off is sharp. Inside the namespace the process is UID 0 with CAP_SYS_ADMIN, so it can mount filesystems, configure network interfaces and create further namespaces. Those capabilities are "scoped": they only work on objects owned by the namespace. But the kernel code that implements mounting or network configuration is the same code root uses, and now an unprivileged user can reach it.

In practiceCreating a user namespace as an ordinary user on the lab VM:

text
$ unshare -Ur sh -c 'id; cat /proc/self/uid_map; grep CapEff /proc/self/status'
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)   ← "root"
         0        501          1      ← uid 0 inside == uid 501 outside; only one id is mapped
CapEff:  000001ffffffffff             ← every capability, but only over this namespace's objects

$ unshare -Ur sh -c 'mount -t tmpfs none /mnt'
mount: /mnt: permission denied.       ← CAP_SYS_ADMIN here does not reach the host's mount table

$ unshare -Urm sh -c 'mount -t tmpfs none /mnt && grep /mnt /proc/self/mounts'
none /mnt tmpfs rw,relatime,uid=501,gid=1000,inode64 0 0   ← add a mount namespace and mounting works
$ grep -c /mnt /proc/self/mounts
0                                     ← ...and nobody outside sees it

That last command is exactly what worries kernel maintainers: an unprivileged user just executed the kernel's tmpfs mount path. Ubuntu decided the risk was too high by default:

text
$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1       ← Ubuntu 24.04 default (introduced in 23.10)

$ unshare -Ur id
unshare: write failed /proc/self/uid_map: Operation not permitted   ← namespace created, capabilities denied

With the restriction on, a build script can create a user namespace but gets no extra powers inside it unless its AppArmor profile grants userns. Container engines ship such profiles; untrusted build steps do not.

Why unprivileged user namespaces widen the kernel attack surface

The reason distributions bother to gate this feature is a long run of kernel bugs that were only reachable once an ordinary user could obtain CAP_SYS_ADMIN inside a namespace. CVE-2022-0185 is the standard teaching example: a bounds-check mistake in the kernel's newer filesystem-mounting code could corrupt kernel memory, and the path to triggering it ran through a capability that only root was ever meant to hold. An unprivileged local user could reach it by first unsharing into a new user and mount namespace. The security point is not the individual bug; it is the pattern.

  1. 1. Harmless-looking featureosunprivileged user namespaces let non-root hold CAP_SYS_ADMIN in a sandbox
    • The capability is "scoped", but the kernel code it unlocks is the same code root runs
  2. 2. Reachable kernel codeattackmount, network config, and nested-namespace paths now accept input from untrusted users
    • Years of bugs (CVE-2022-0185 in the mount API, others in networking) lived in exactly this code
  3. 3. The gatedefenddistributions disable unprivileged userns, or gate it behind AppArmor (Ubuntu), or require seccomp to block the entry syscalls
    • Shrinking what an untrusted job can reach beats hoping the reached code is bug-free
📰

Real incident — CVE-2022-0185 (2022). A heap buffer overflow in the Linux kernel's Filesystem Context code (the fsconfig path added in 5.1) let a local user corrupt kernel memory and escalate to root, and from inside a container it enabled a full host escape. Reaching it required CAP_SYS_ADMIN, which an unprivileged user could obtain by creating a new user namespace. It affected kernels 5.1 through 5.16 and was fixed in early 2022. Lesson: every capability you hand an untrusted workload, even a "namespaced" one, is a licence to run more kernel code; the durable defence is to deny the entry syscalls (unshare, clone with CLONE_NEWUSER) with seccomp rather than trust that the unlocked code is flawless.

cgroups v2: the meter that stops the noisy neighbour

Namespaces hide things; they do not ration anything. A build that forks endlessly or allocates until the host swaps to death is isolated by namespaces and still takes the machine down. Control groups version 2 (cgroups v2) is the kernel's accounting and limiting layer: every process belongs to one group, and the group has files that cap and meter CPU time, memory, process count and block I/O.

In practiceA container with explicit limits, read from inside it:

text
$ sudo docker run --rm --memory=256m --cpus=0.5 --pids-limit=64 alpine \
      sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/pids.max'
268435456           ← memory.max: 256 MiB, then the OOM killer fires inside this group only
50000 100000        ← cpu.max: 50 ms of CPU per 100 ms window = half a core
64                  ← pids.max: a fork bomb hits 64 and gets EAGAIN, host untouched

When a group hits its CPU ceiling the kernel does not error; it throttles, and that shows up as latency, not failure. The counters make it visible:

text
$ sudo docker run --rm --cpus=0.5 alpine sh -c 'yes>/dev/null & yes>/dev/null & sleep 3; cat /sys/fs/cgroup/cpu.stat'
nr_periods 31
nr_throttled 31              ← throttled in every single scheduling period
throttled_usec 4552700       ← 4.5 s of wall time spent parked, waiting for quota
🎯

On the job. When a developer says "CI got slow this week and nothing changed", nr_throttled climbing against nr_periods on the runner's cgroup is the smoking gun: the job is CPU-capped and spends its time parked, not working. The fix is more quota or fewer parallel jobs, not more profiling. The same throttled_usec metric is what a multi-tenant platform watches to prove one tenant is not starving another. See the performance-debugging deep dive for reading these under load.


seccomp-BPF: filtering the request before the kernel reads it

What it isseccomp (secure computing) mode 2 attaches a small BPF program to a thread. For every syscall the thread makes, the program sees the syscall number and the raw register values of the arguments, and returns an action: allow, return an error, trap, notify a supervisor, or kill. It is the cheapest sandbox layer because the check is a tiny program run on a path the kernel already takes.

The one limitation that mattersThe BPF program sees argument registers, which for a pointer means the address, not what it points at. seccomp deliberately cannot dereference a pointer. If it followed the pointer to inspect, say, the path passed to openat(), a second thread could change that memory between the check and the kernel's use of it: a time-of-check-to-time-of-use (TOCTOU) race that would make the filter lie. So seccomp can enforce "never call mount()" or "socket() only with these address families", but it cannot enforce "openat() anything except /etc/shadow". That gap is precisely what Landlock fills.

defaultActionSCMP_ACT_ERRNO
allow-listnames → SCMP_ACT_ALLOW
denied in Docker default~44 syscalls
no_new_privsrequired
defaultAction
unlisted syscalls return an error (Docker's default), not killed
allow-list
the ~300+ syscalls a normal program needs
denied in Docker default
mount, umount2, kexec_load, init_module, ptrace, pivot_root, reboot, swapon…
no_new_privs
an unprivileged thread must set it before installing a filter, so a set-uid binary cannot escape the filter

The privilege ruleInstalling a filter needs either CAP_SYS_ADMIN or the no_new_privs bit already set. The reason is subtle and worth saying out loud in an interview: without it, an unprivileged user could install a filter that makes a set-user-ID helper misbehave (deny a syscall it relies on for a security check) and ride it to root. no_new_privs closes that by promising the kernel this thread can never gain privilege through exec.

Beyond allow/denySECCOMP_RET_USER_NOTIF (Linux 5.0) hands the syscall to a user-space supervisor that decides what to do, so a runtime can emulate a dangerous syscall safely instead of flatly denying it. SECCOMP_FILTER_FLAG_TSYNC applies one filter to every thread in the process at once, so a multithreaded Go runner cannot leave one thread unfiltered. Return actions have a fixed precedence, kill beats trap beats errno beats allow, so when filters stack the most restrictive decision wins.

In practiceDocker's default filter is what stops the user-namespace trick inside an ordinary container:

text
$ sudo docker run --rm alpine sh -c 'unshare -Ur id; mount -t tmpfs none /mnt'
unshare: unshare(0x10000000): Operation now in progress ... Operation not permitted   ← CLONE_NEWUSER denied by seccomp
mount: permission denied (are you root?)                                               ← mount() denied by seccomp

The container is nominally root with 14 capabilities, yet it cannot create a user namespace or mount anything, because the seccomp filter refuses the syscalls before the capability check is even reached.

📰

Real incident — Dirty Pipe (CVE-2022-0847, 2022). A flaw in the kernel's pipe handling let an unprivileged process overwrite data in files it could only read, including root-owned files, enough to escalate to root on the host and, from a container, to tamper with files in the host's shared page cache. The trigger used ordinary, always-allowed syscalls (splice, write), so no seccomp profile stopped it: the calls themselves are legitimate. Lesson: seccomp shrinks the set of reachable kernel bugs, but a bug in a syscall every program needs is outside its reach. That is the ceiling that pushes untrusted workloads toward gVisor (its own pipe code) or a VM (its own kernel). The sandbox isolation deep dive traces Dirty Pipe through each runtime.

Tip

See it yourselfThe sandbox-isolation lab Level 9a has you write a seccomp profile that blocks one socket family and watch an exploit attempt fail at the first syscall, then Level 5 shows the same program allowed, denied and killed by the three return actions.


Landlock: unprivileged, object-level sandboxing

What it isLandlock is a Linux Security Module (LSM) that any process, including an unprivileged one, can use to sandbox itself by object: "code running after this point may read under /usr, and nothing else." Because it runs at the LSM hooks inside the kernel, after a path has been resolved to a real file, it can do what seccomp cannot: judge the file, not the syscall number. It first shipped in Linux 5.13 and has grown an ABI version per release since.

Build a ruleset

landlock_create_ruleset() declares which access types you are going to govern (read file, write file, bind a TCP port…).

Add rules

landlock_add_rule() with a PATH_BENEATH rule grants an access set to everything under one opened directory (opened with O_PATH).

Enforce

landlock_restrict_self() applies it to this thread and all future children. Restrictions only add; you can never loosen later.

no_new_privs

an unprivileged caller must set it first, for the same reason seccomp does.

The ABI version tells you what a kernel's Landlock can govern, and it grows every release, so a CI job must query it and degrade gracefully rather than assume:

ABI 1Linux 5.13
ABI 2adds
ABI 3adds
ABI 4adds
ABI 5+adds
ABI 1
filesystem read/write/execute access rights
ABI 2
reparenting files between directories (LANDLOCK_ACCESS_FS_REFER)
ABI 3
file truncation control
ABI 4
TCP bind and connect port restrictions ← network, not just files
ABI 5+
ioctl-on-device control, then IPC/signal scoping, audit flags, all-threads enforcement

In practiceA real self-sandbox on the lab VM (kernel 6.8 reports Landlock ABI 4). A short program declares every filesystem right as "governed", then grants read-only access under /usr and nothing else, and enforces it on itself:

text
$ cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor        ← landlock is active in this kernel

$ python3 landlock_selfbox.py          # create ruleset → PATH_BENEATH read on /usr → no_new_privs → restrict_self
restrict_self -> 0                     ← the sandbox is now live for this process
open /usr/bin/true  OK                 ← allowed: it is under /usr
open /etc/hostname  -> Permission denied   ← denied: outside every granted hierarchy

Note what happened: the process sandboxed itself, with no root, no profile loaded by an administrator, no container runtime. That is Landlock's reason to exist. A build tool, a parser, an image resizer can wrap its own risky stage.

Landlock vs AppArmor/SELinuxAll three are object-level MAC. The split is operational:

Landlock

is applied by the program to itself at run time and needs no privilege, but it only governs the access types its ABI knows, and an older kernel silently supports less.

AppArmor

(path-based) and SELinux (label/inode-based) are loaded by the administrator for the whole system and can express far richer policy, but a CI job cannot author one at run time, and a wrong profile either breaks the app or, worse, fails open. CVE-2022-0492 (the cgroup release_agent escape) was in practice blocked on most hosts precisely because the default SELinux/AppArmor policy denied the write, even though the kernel's own check was missing.

🎯

On the job. In a code review, a service that spawns helpers to handle attacker-controlled input (thumbnailing uploads, running a user-supplied linter) is the place to ask "can this helper be wrapped in Landlock and a seccomp filter?" It is a small, unprivileged change that caps the blast radius of a bug in the helper, and unlike an AppArmor profile it ships with the code and needs no fleet-wide rollout.


Layering: the order the primitives go on

For a CI runner executing a build it does not trust, the layers are applied in a specific order during process startup, because each one constrains the next:

  1. 1. New namespacesosclone/unshare into fresh mount, pid, net, ipc, uts (and user, if the host allows it)
    • An empty network namespace + a veth to an egress proxy = default-deny networking
  2. 2. cgroup v2 limitsosmemory.max, cpu.max, pids.max set by the parent before the child runs
    • Caps the damage of runaway or fork-bomb builds; one tenant cannot starve another
  3. 3. Drop capabilities + no_new_privsdefendprctl(PR_SET_NO_NEW_PRIVS, 1) so later steps cannot be escaped via set-uid
    • Must come before seccomp/Landlock for an unprivileged process
  4. 4. Landlock rulesetdefendread-only the toolchain, read-write only the scratch and output dirs
    • Query the ABI first; degrade, don't crash, on older kernels
  5. 5. seccomp filterdefenddeny mount, ptrace, kexec, module load, unshare(CLONE_NEWUSER)…
    • Installed last so the earlier setup syscalls are still permitted

Why that orderno_new_privs must precede seccomp and Landlock or an unprivileged process cannot install them. The namespaces and cgroup must be set up before the filter, because creating them needs syscalls (unshare, mount) that the final seccomp filter will forbid. Get the order wrong and either the setup fails or a gap is left open. This is the sequence a hardened systemd unit encodes declaratively (RestrictNamespaces=, SystemCallFilter=, MemoryMax=), and the sequence a Go runner supervisor runs by hand before exec-ing the build.

📰

Real incident — Leaky Vessels (CVE-2024-21626, 2024). runc, the low-level runtime under Docker, containerd and most of Kubernetes, leaked an open file descriptor pointing at the host filesystem into the container. A malicious image could set its working directory to that descriptor and reach the host root, escaping every namespace. Lesson: namespaces restrict the names a process can use, not the handles it already holds; a leaked file descriptor is a hole no namespace closes. Layering matters because a seccomp or Landlock rule, or simply running the whole thing in a VM, limits what that escaped handle can then do.


Workload identity: binding credentials to the sandboxed process

A sandbox decides what a job may do to the machine. Workload identity decides what it may prove to others. The two have to be designed together, because the classic CI attack is not a kernel escape at all: it is a malicious dependency in a build step reading the long-lived cloud key sitting in the runner's environment and mailing it out.

The modern pattern removes the standing secret. The runner proves its identity with a short-lived OpenID Connect (OIDC) token the CI platform mints per job, exchanges it for a scoped, minutes-long cloud credential, and that credential is bound to the specific job's claims (repository, ref, environment). There is nothing durable to steal, and a stolen token expires before it is useful.

Bind the token to the sandbox, not the host

the OIDC token and any exchanged credential should live only inside the job's mount and process namespace, in a tmpfs that is destroyed with the job, never on a path a sibling job or the next job can read.

Scope the exchange tightly

the cloud trust policy must pin the exact repository (by immutable ID, not a path string that can be renamed into) and the exact ref, so a fork or a pull_request build cannot mint a production credential. This is where most real misconfigurations live. The workload identity federation deep dive covers the token-verification and trust-policy traps in full.

Starve the exfiltration path

even a scoped token is worth stealing for its lifetime, so pair identity with the egress controls below: a build step that cannot reach an attacker's server cannot send the token anywhere.

🎯

On the job. The review smell is any CI pipeline with a cloud access key in its secrets store. Ask "why is this static?" The answer is almost always "we set it up before OIDC federation existed." Replacing it with a per-job token exchange is the single highest-value change you can make to a CI platform's credential posture.


Network isolation and egress: cutting off exfiltration and metadata theft

The sharpest network risk in CI is the cloud metadata service at 169.254.169.254: a link-local address every instance can reach that hands out the node's own IAM credentials. An untrusted build that reaches it inherits the node role. The network namespace is the primitive that closes it.

  1. 1. Empty network namespacenetworkthe job starts with only loopback — no default route, no metadata address reachable
  2. 2. One veth to an egress proxynetworkthe only route out is a proxy you control
  3. 3. Default-deny allowlistdefendthe proxy permits the package registries the build needs, by name, and nothing else
    • 169.254.169.254 and RFC1918 ranges are never on the list
  4. 4. Log every allowed and denied connectiondefendthe allow/deny stream is the audit trail and the detection signal

Because the job is in its own network namespace with no route to the metadata address, the credential theft fails at the routing layer, before any allowlist decision. This is cheaper and more reliable than trying to filter the one address with firewall rules on a shared interface. The untrusted-workload egress deep dive covers the proxy, DNS-vs-IP allowlisting and DNS-tunnelling defences; the point here is only that the network namespace is the enabling primitive.

🎯

On the job. A quick test of any "isolated" runner: from inside a job, does curl http://169.254.169.254/ connect? If it does, the runner can steal the node's cloud identity, and no amount of secret-scanning fixes that. The durable answer is an empty network namespace plus an egress proxy, not a deny rule bolted onto a shared network.


When the primitives are not enough

Everything above lives inside the one host kernel. seccomp shrinks the reachable syscalls, Landlock and MAC guard the objects, namespaces hide the world, cgroups meter it, but a bug in a syscall you must allow (Dirty Pipe used splice) is still a bug in the shared kernel. For code you genuinely do not trust, the honest answer in an interview is that defence-in-depth with these primitives raises the cost of an escape but does not remove the shared-kernel assumption. That is the moment to reach for a second kind of wall:

gVisor

answers the syscalls itself in a user-space kernel written in Go, so the untrusted program almost never reaches the host kernel. Its cost is per-syscall overhead.

A microVM (Firecracker/Kata) or Apple's Virtualization framework

gives the job its own guest kernel behind a hardware boundary, so a guest-kernel bug stays in the guest. Its cost is density and boot time.

The decision, and the 2026 escape history of each, is the subject of the sandbox isolation deep dive. The primitives on this page are what you use inside whichever wall you pick: even a VM runs its workload under seccomp, dropped capabilities and cgroup limits.


Interview Questions

Q
Why can't seccomp-BPF safely block openat() on a specific path like /etc/shadow, and how does Landlock solve it?
Model answer

A seccomp filter only sees the syscall number and the raw argument registers, and for a path argument that register is a pointer, just an address. If seccomp followed it to read the path, another thread could change that memory between the check and the kernel's use of it, a time-of-check-to-time-of-use race, so the filter would be lying. Landlock runs at the LSM hooks inside the kernel, after the path is resolved to a real inode, so it can judge the actual object. Rule of thumb: seccomp filters the verb and its numbers, Landlock filters the noun.

Q
What are the trade-offs between an unprivileged Landlock sandbox and system-wide AppArmor or SELinux profiles?
Model answer

Landlock is applied by a program to itself at run time, needs no privilege, and ships with the code, so a build tool can sandbox its own risky stage with no fleet rollout, but it only governs what its ABI version knows and silently supports less on older kernels. AppArmor and SELinux are loaded by an administrator for the whole system and express far richer policy, but a CI job can't author one at run time and a wrong profile breaks the app or fails open. In practice you use both: system MAC as the baseline, Landlock as the application's own last layer.

Q
If an untrusted CI job runs in an unprivileged user namespace with CAP_SYS_ADMIN, what kernel attack surface is exposed, and how do cgroups v2 help?
Model answer

Inside a user namespace the job is root with full capabilities scoped to that namespace, which unlocks kernel code paths ordinary users never reach, filesystem mounting, network configuration, nested namespaces, and that is exactly where bugs like CVE-2022-0185 lived. The capabilities are scoped, but the kernel code behind them is shared, so it's an attack-surface problem, not a capability problem. cgroups v2 don't touch that surface; they ration CPU, memory and process count so a job that can't escape still can't starve its neighbours or fork-bomb the host. The durable fix for the surface is to deny the entry syscalls with seccomp.

Q
In what order does a hardened runner apply PR_SET_NO_NEW_PRIVS, Landlock, seccomp and namespaces, and why that order?
Model answer

Namespaces and cgroup limits first, because creating them needs syscalls like unshare and mount that the final filter will forbid; then no_new_privs, because an unprivileged process can't install seccomp or Landlock without it; then the Landlock ruleset; then the seccomp filter last, so all the earlier setup calls were still allowed when they ran. Get it backwards and either the setup fails or you leave the escape-via-setuid gap that no_new_privs is there to close.

Q
When is layering namespaces, Landlock and seccomp insufficient, and what do you move to?
Model answer

When the threat is a bug in a syscall you have to allow. Dirty Pipe used splice and write, calls every program needs, so no seccomp profile stopped it, and it hit the shared host kernel. Once you accept you're running code that may carry a kernel exploit, you add a wall of a different kind: gVisor, which answers syscalls in a user-space kernel so the host kernel is almost never reached, or a microVM like Firecracker or Kata that gives the job its own guest kernel behind hardware virtualisation. The primitives still apply inside that wall; they just stop being the only wall.

Q
How do you stop an untrusted build from stealing the node's cloud credentials, and which primitive does the work?
Model answer

Start the job in its own empty network namespace with only loopback, then give it a single virtual link to an egress proxy that allowlists the package registries by name and nothing else. Because the job has no route to the metadata address at 169.254.169.254, the classic credential theft fails at routing, before any filter decision, which is far more reliable than a deny rule on a shared interface. The enabling primitive is the network namespace; the proxy and allowlist are the policy on top. Pair it with per-job OIDC tokens so there's no standing secret worth stealing in the first place.

Q
How should ephemeral workload identity be bound to a sandboxed job to prevent cross-job credential leaks?
Model answer

Mint a short-lived OIDC token per job, exchange it for a scoped cloud credential that expires in minutes, and keep both only inside the job's own mount and process namespace, in a tmpfs destroyed when the job ends, never on a path the next job can read. Scope the cloud trust policy to the exact repository by immutable ID and the exact ref, so a fork or pull-request build can't mint a production credential. The sandbox stops the job tampering with the host; the per-job, namespace-bound token stops it stealing an identity that outlives it.