Back to the map

Module 09 · Security and protection · Lesson 3 of 3

Sandboxing and mandatory access control

Confining a process even after it is compromised: DAC versus MAC, why chroot is not a sandbox, seccomp-bpf filters, SELinux and AppArmor, how Chrome and container runtimes layer them, and when gVisor or Firecracker is the next step.

CoreShould-knowMid 13 min Updated Nov 2026

Why it matters

A browser renders pages written by strangers, a CI service builds pull requests from anyone, a serverless platform runs customer functions on shared hosts. In each case the question is not "who is this process?" but "what can it do once it has been taken over?". The 2019 runc escape and the operator who typed setenforce 0 to make a deploy work are both stories about these layers.

The idea

Sandboxing means running code under a policy chosen by whoever deploys it, enforced by the kernel, that the code cannot loosen. Each layer below removes one class of escape; real systems stack several.

DAC versus MAC

The previous lesson's permissions are discretionary access control: the owner decides. Mandatory access control adds a second check the owner cannot relax: an administrator writes a policy, the kernel labels every process and object, and a request must pass both DAC and the policy. On Linux the engines plug into the Linux Security Modules hooks. A compromised nginx running as www-data may own /var/www, but under MAC it still cannot read /etc/shadow or run /bin/sh unless the policy says so.

chroot, and why it is not a sandbox

chroot /jail changes what / resolves to for the calling process, and nothing else: it still shares the host's PIDs, network, devices and system calls, and a root process inside can create a directory, chroot into it and cd .. its way out. Container runtimes use pivot_root inside a mount namespace, which detaches the old root entirely.

seccomp-bpf: filtering system calls

Every interaction with the world is a system call (the System calls lesson). seccomp-bpf attaches a small BPF program that the kernel runs on every call, inspecting number and arguments, and returning allow, errno, kill or log. It is inherited by children, cannot be removed, and requires no_new_privs. Docker's default profile blocks about 44 calls: mount, bpf, keyctl, open_by_handle_at and clone with namespace flags. It costs tens of nanoseconds per call; the risk is a call the profile never heard of: in 2020 old profiles returned EPERM for clone3, and glibc, which expects ENOSYS, misbehaved.

SELinux and AppArmor

SELinux labels everything. Processes and files carry types (httpd_t, httpd_sys_content_t) and rules say which types may do what to which (type enforcement). Containers run as container_t with a unique pair of MCS categories each, so two root processes in different containers cannot read each other's files. Modes are enforcing and permissive (log but allow). A denial lands in the audit log:

avc: denied { read } for pid=4132 comm="nginx"
  scontext=system_u:system_r:httpd_t:s0
  tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

Read it as: a process of type httpd_t tried to read a file labelled user_home_t and was refused. The fix is to label the file correctly (restorecon -Rv /var/www), flip a boolean with setsebool, or add :Z to a container volume so the runtime relabels it.

AppArmor confines by program path rather than label. A profile per binary lists what it may touch: /var/www/** r,, deny /etc/shadow r,; it runs in complain or enforce mode. Docker's docker-default profile denies mounts and writes under /proc/sys. Debian and Ubuntu ship AppArmor; RHEL and Android ship SELinux.

LayerDecides onWho can loosen itTypical break
DAC (mode bits, ACLs)Identity of the processThe file ownerCompromised owner reads all it owns
chrootWhere / points for path lookupsRoot inside, triviallyNot a boundary at all
seccomp-bpfSystem call number and argumentsNobody, once installedNew system call returns EPERM
AppArmorProgram path against a profileAdministratorFile moved to an unlisted path
SELinuxLabels on processes and objectsAdministratorMislabelled file after a copy or mount
From weakest to strongest; strength comes from stacking them.

How browsers and container runtimes stack the layers

Chrome runs each site's renderer in its own process with empty user and network namespaces, all capabilities dropped and a strict seccomp-bpf filter; it reaches disk and network only by asking the browser process over IPC. A renderer exploit therefore needs a second bug, a sandbox escape, to matter.

The Containers: namespaces and cgroups lesson covers the view and the limits; the security layers sit on top.

Application processpossibly compromisedNon-root UID, capabilities dropped, no_new_privsseccomp-bpf filterabout 44 calls blockedMAC profileAppArmor or SELinux container_tNamespaces, cgroups, pivot_root, masked /procShared host kernelone bug here crosses every layer aboveOptional: gVisor or Firecrackerescape path
Layers of a container sandbox. An escape must pass every layer.

gVisor and Firecracker: the next step

Every layer above shares the host kernel, and a kernel bug such as Dirty Pipe (2022) crosses all of them. gVisor puts a kernel written in Go between the container and the host: it intercepts the container's system calls and implements them itself; the price is compatibility gaps and slower system-call-heavy work. Firecracker runs each workload in a microVM, booting in about 125 milliseconds; AWS Lambda runs on it. Rule of thumb: your own code gets a container; a stranger's code gets a VM boundary as well.

Trade-offs

Confinement is only as good as the policy. The common failure is not a bypass but an operator disabling a layer: setenforce 0, --privileged, seccomp=unconfined, because a denial blocked a release. gVisor and microVMs cost latency and memory. The payoff is that a successful exploit lands in a process that can do almost nothing.

In an interview

On running untrusted code: "Each job runs as a non-root user with all capabilities dropped, the runtime's seccomp profile and an AppArmor profile; because customer code is involved we run it under Firecracker as well, since containers share the host kernel." On an SELinux denial: "I read the AVC line, source type, target type and class, then fix the label with restorecon or the volume's :Z flag rather than switching enforcement off."

Check yourself

On a Fedora host, a container bind-mounts /srv/data and gets 'permission denied', although the directory is mode 0755 and owned by the container's UID. The audit log shows an AVC denial with tcontext type default_t. What is the fix?

Sandboxing and mandatory access control · Backplane