pk.org: Computer Security/Lecture Notes

Command Injection and Containment

Part 2 - Containment

Paul Krzyzanowski – 2026-10-22

The Zimbra mail servers were not running their mail service as root. The service ran under its own account, zimbra, and the injected commands ran with that account’s rights. That is a step toward the Principle of Least Privilege, but it was not enough.

The account was permitted to run several helper programs as root through sudo. It could read the credentials the service stored. It held an SSH key that logged in to every other mail server in the cluster. Each of those was authority that the mail service did not need while it was handling mail, and each became the attacker’s the moment the service was compromised.

Microsoft’s recommendations to the operators of those servers included confining the service accounts “with namespace, seccomp, or AppArmor controls.” All three are mechanisms for containment: limiting what a process can see, do, and consume. The limits are enforced from outside the process, so they hold after the process has been taken over.

Containment starts from the assumption that some program on the system will be compromised. Injection, memory corruption, and the other faults we have examined are bugs, and careful programming reduces their number but does not necessarily reach zero.

Containment does not prevent the compromise. It decides in advance how much a compromised program can reach.

We begin with why ordinary access control does not provide this, and with the requirements that a containment mechanism has to meet. The mechanisms then follow in the order of what they restrict: what a process can see, what it can do, what it can touch, and what its code can express. After that come the systems that combine those mechanisms: application sandboxes on phones, the web browser, containers, and virtual machines. We end with virtualization-based security, which uses a hypervisor to protect an operating system from its own kernel, and with a comparison of every mechanism against the same five questions.

Why Access Control Is Not Enough

The access control mechanisms of an operating system identify users, not programs. When a user runs a program, the process runs with that user’s identity, and the kernel checks every file access against that identity. This means that a text editor can read the user’s mail, and a mail reader can delete the user’s source code. The operations are permitted because the kernel does not care which program is making the request, and the permission model cannot express rules about programs.

A compromised process, therefore, holds every right its user holds. If the user is root, the attacker can create accounts, install programs, modify configuration files, and change file permissions or ignore them. If the user is an ordinary account, the attacker can still read and modify every file that the account owns, install programs in the account’s search path, and communicate over the network as that user.

The workaround that services have used for decades is the one Zimbra used: create a dedicated account for the service and run the service as that account. That limits the service to what the account can access, but that depends on the “other” permission bits of every file on the system. Any file that is world-readable is readable by the compromised service. Access control lists cannot express the rule we actually want, which is that this web server is only allowed to access a few specific files.

Recall the Principle of Least Privilege: a program should run with the fewest permissions it needs to do its job. It is a design principle, and it says what we want, but not how to get there. We have already seen four ways to approach it:

  1. Run the program under a dedicated account, as a service started at boot or as a setuid program. Access control then limits the program to what that account can reach, which still includes every world-readable file.

  2. Have the program drop privileges after it has opened the resources it needs.

  3. Split the program into a small privileged part and a large unprivileged part, which is privilege separation.

  4. Run the program on its own machine. The program can reach only what that machine holds, and the limit does not depend on the program’s code or on the permission model. The overhead is an entire machine per program.

The second and third depend on the program’s own code doing the right thing, and on the programmer having predicted what the program needs. The fourth is the strongest and the most expensive.

Containment is a fifth way, and it brings the idea of the dedicated machine inside one machine. The limits are enforced from outside the program, so a confined program cannot restore rights it dropped, and a program that was never written to restrict itself can still be held to the principle by whoever runs it. Containers and virtual machines, which we will cover in detail later, are the mechanisms that make a dedicated machine cheap enough to give every program one.

A second reason for containment is unrelated to bugs. Much of the code that runs on a computer was never fully trusted in the first place. A phone runs apps from strangers. A browser runs scripts from every site its user visits. A build pulls in hundreds of dependencies that nobody on the team has read, and a program written by an AI coding agent may be run before anyone has read it either. We decide to run this code without knowing what it does, and containment is what makes that decision safe enough to take.

The environment that confines a program is called a sandbox, and running a program under containment is called sandboxing it. The term covers the whole range of mechanisms that follow. A filter that limits a process to a few system calls is a sandbox, and so is a virtual machine. What distinguishes one sandbox from another is what the confined program can still reach, and the mechanisms that follow differ in where they draw that line.

What a Confined Process Must Be Kept From

Files are the obvious resource, but a compromised process can do damage through several channels that file permissions do not cover:

A containment mechanism has to decide which of these it controls. Most of the early ones controlled only files.

Requirements for a Containment Mechanism

The reference monitor from the 1972 Anderson report, the U.S. Air Force study that introduced the term, had three requirements, and they apply here without change. A containment mechanism has to mediate every access by the confined process. It has to be tamperproof, so that the process cannot modify or bypass it. And it has to be small enough to verify. Two practical requirements join them. The mechanism should be easy to configure, since a policy that administrators cannot understand will be written wrong, and it should work with most programs without modifying them.

Every mechanism trades among these. A filter written in a low-level language can be verified but is hard to configure. A mechanism that works with any program unmodified usually cannot tell what the program intends. A mechanism that prompts the user to approve each access mediates everything and ends up enforcing whatever the user clicks.

To compare them, we ask the same five questions about each one:

  1. What does it restrict?

  2. Who enforces it?

  3. What interface can the confined code still reach?

  4. What overhead does it add?

  5. How does it fail?

A boundary is only as strong as the interface the confined code can still reach and the correctness of the layer that enforces it.

Where the Enforcement Sits

The second question sorts every mechanism into four levels, ordered by how far the enforcement sits from the confined program:

  1. A user-level process. A monitor in another process watches the confined program through the debugger interface. Janus and Systrace, the earliest system call sandboxes, work this way.

  2. A language runtime. The Java virtual machine, a JavaScript engine, or WebAssembly runs the program and controls what its code can express. The program has no way to write a raw pointer or a system call.

  3. The kernel. chroot and jails, seccomp-BPF, AppArmor, namespaces, control groups, and capabilities are all enforced by the kernel, and a container combines all of them.

  4. The hypervisor. Virtual machines and virtualization-based security are enforced beneath the kernel, by the hypervisor and the processor.

Two things change as we go down the list. The enforcement gets harder to subvert, because it sits in a smaller and more privileged layer, and the interface the confined code can reach gets narrower, from the whole system call interface at the top to virtual hardware at the bottom. Each level trusts everything beneath it: a monitor trusts the kernel that stops the process for it, a language runtime trusts the kernel that runs it, a container trusts the kernel it shares, and a virtual machine trusts the hypervisor.

Application sandboxes on phones and the web browser are not levels of their own. They are combinations, and each of them uses several levels at once.

What Containment Does Not Do

A covert channel is one that transfers information through a mechanism not intended for communication, bypassing the system’s security restrictions. For example:

Containment limits the channels a program is meant to use: files, sockets, system calls, and shared memory. A confined program that is forbidden to write a file can still signal to a collaborator through how much CPU it uses, how long its operations take, or whether a shared cache is warm or cold. Those channels exist in every mechanism described below, including virtual machines. None of these mechanisms were designed to close them.

Restricting What a Process Can See: chroot and Jails

The oldest containment mechanism in Unix changes what a process can name. The chroot system call appeared in Version 7 Unix in 1979, and it was built for a mundane purpose. A software distribution could be built and tested in a separate directory tree without the files on the development machine leaking into it. BSD added the call in 1982.

The call changes where path resolution starts for a process and for every child it creates. After a web server runs chroot("/local/httpd"), the absolute path /etc/passwd resolves to /local/httpd/etc/passwd, and the process cannot name the real /etc/passwd at all. The subtree is called a chroot jail. Administrators used it to confine network services, so that a web server could serve files only from its own tree.

The setup is tedious. Every executable the service runs, every shared library those executables load, and every configuration file they read has to be copied into the tree. A missing library produces a failure only when the code that needs it runs. Only root may call chroot, because a user who could set up a tree with a forged /etc/passwd and then run a setuid program inside it would control what that program read.

How a Process Escapes

A process that runs as root inside the jail is not confined by it, because root can call chroot again. The chroot system call changes only the process’s root directory. It does not change the working directory, which is why a program that sets up a jail calls chdir afterward. The attacker makes the second chroot call and skips the chdir, so it does not matter how carefully the jail was set up. The classic escape takes three steps:

  1. Create a subdirectory inside the jail and call chroot on it. The root moves down to the subdirectory, but the working directory stays where it was, inside the old jail, which is now above the new root.

  2. Call chdir("..") repeatedly. The kernel stops .. only when the working directory is the root itself. Since the working directory is already above the root, .. never reaches it, and the working directory climbs to the real root of the filesystem.

  3. Call chroot("."), and the jail is gone.

A root process has other options. It can create a device node for the disk with mknod and read the raw disk, including every file outside the jail. It can send signals to processes outside the jail and attach to them with ptrace. It can mount filesystems. And a file descriptor opened before the chroot call stays open afterward, pointing wherever it pointed before.

Even for a process without root, chroot restricts only names. The process can make every system call, open network connections, use the machine’s address, fork without limit, and consume every resource on the machine. The Linux manual page for the call states that it “is not intended to be used for any kind of security purpose.”

FreeBSD Jails

In March 2000, FreeBSD 4.0 shipped jails, built for a hosting company that wanted to give each customer root on a shared machine. A jail starts from a chroot tree and adds what chroot lacks. Processes in a jail see only the processes in the same jail. Each jail has its own hostname and its own IP address, and processes in it cannot use any other. The root user inside a jail cannot mount filesystems, load kernel modules, create raw network sockets, or change kernel settings.

The lessons of chroot and jails apply to every mechanism that follows. Hiding names is not the same as restricting operations, since a process that cannot name a file may still reach it through a device, a descriptor, or another process. And root inside a boundary has to be a weaker root than root outside it, or the boundary holds only until the confined program finds a way to become root.

Restricting What a Process Can Do: System Call Filtering

A process acts on the world only through system calls. It opens files, creates sockets, sends signals, and starts programs by requesting each of those things from the kernel, and the kernel is the only code that can do them. The system call interface is therefore the natural place for complete mediation. Every operation a confined process could use to cause harm passes through it, and a policy applied there sees all of them.

Monitoring From Outside the Process

The first system call sandboxes placed the policy enforcement in a separate process. In 1996, a group at the University of California, Berkeley, built Janus to confine the external viewers that web browsers of the time launched to display downloaded files, which browsers called helper applications, such as a PostScript viewer, an image viewer, or a video player. The browser itself was not confined. A PostScript viewer was a full interpreter that could be made to run anything, and the downloaded file was the attacker’s input to it.

Instead of launching the viewer directly, the browser launched Janus with the viewer’s command line. Janus started the viewer as its child process under the Solaris operating system’s process-tracing facility, the same facility a debugger uses.

There were two processes: the confined program and the monitor, which was Janus itself. Each confined program ran under its own monitor, which stayed alive as its parent and also traced any processes it created.

Every system call went through the kernel to the monitor. When the confined program made a system call, the kernel stopped it at the entry to the call and woke the monitor. The monitor read the call number and its arguments out of the confined program’s memory and checked them against a policy file, which listed which files the program could open and whether it could use the network. If the policy allowed the call, the monitor let the kernel perform it. If not, the monitor made the call fail, and the confined program saw an error return. The monitor never performed an operation itself; it only decided whether the kernel could.

The design had three problems.

  1. The first was that the monitor had to replicate kernel state. To decide whether open("../secret") was permitted, the monitor had to know the process’s working directory and follow symbolic links the way the kernel would. It also had to track every change the process made to either. A later version of Janus for Linux moved part of that work into a kernel module. Its authors reported that the monitor’s record of a socket’s type could be made stale by dup2(), and the fix was to query the kernel rather than keep a copy.

  2. The second problem was the possibility of race conditions. The monitor read a file name from the process’s memory, approved it, and let the call proceed. Between the approval and the moment the kernel read the same memory, another thread in the process could overwrite the name with a different one. The kernel opened a file that the monitor never saw. This is the same time-of-check to time-of-use failure as the access() and open() race condition, with the monitor as the checker. Systrace, a similar tool that shipped with OpenBSD in 2002, had the same class of flaw. In 2007, a researcher demonstrated working exploits against it and against two other wrapper systems built the same way.

  3. The third problem was overhead. Every monitored system call required two context switches to the monitor and back, and system calls are frequent.

seccomp and seccomp-BPF

Linux moved the filter into the kernel. The first version of seccomp (secure computing mode), added to Linux 2.6.12 in 2005, was written for a project that let people sell their spare CPU time to strangers. A process that entered the mode could make exactly four system calls: read(), write(), _exit(), and sigreturn(). Any other call killed it. That was enough to run a computation on data arriving through a pipe, but an ordinary program could not run under it.

seccomp-BPF, added to Linux 3.5 in 2012, generalizes this. A process installs a small filter program, and the kernel runs the filter on every system call the process makes before the call executes. The filter sees the system call number and the values in the argument registers. It returns one of four decisions:

  1. allow the call,

  2. make it fail with a chosen error code,

  3. deliver a signal to the process,

  4. or kill the process.

A filter is inherited by every child process and survives execve(). A process can add filters, each of which can only remove permissions, but it cannot remove one.

The filter language is BPF, the Berkeley Packet Filter, which Unix kernels have used since the early 1990s to decide which network packets a tool such as tcpdump should see.

It was reused for two reasons. It was already in the kernel, and the kernel already verified BPF programs before loading them. A program has a bounded length and cannot jump backward, so a filter always terminates and cannot loop or crash the kernel. An engineer at Google wrote seccomp-BPF to sandbox Chrome’s rendering processes, and it remains the primary sandbox in Chrome on Linux.

Why seccomp Cannot Check File Names

A filter can make decisions only on integers. The arguments to open() include a pointer to the filename, and the name itself resides in memory controlled by the process.

If the filter followed the pointer and read the name, another thread could change the name after the filter read it and before the kernel used it, which is the Janus race condition again. The designers of seccomp-BPF refused to read process memory at all.

That eliminates the race condition, and it also means that the filter cannot distinguish opening /tmp/scratch from opening /etc/shadow. It can allow or forbid open(), and it can inspect the flags argument to permit read-only opens and forbid writes, since the flags are an integer. Restricting which files a program may open requires a different mechanism, described in the next section.

What System Call Filtering Is For

Most programs use a small fraction of the system calls the kernel offers. A web server never needs to load a kernel module, reboot the machine, or change the system clock, and a filter that forbids those calls leaves the server unaffected. The benefit is that kernel code behind the forbidden calls is no longer reachable from the process. Many kernel privilege-escalation bugs are in rarely used calls, and a process that cannot make the call cannot exploit the bug.

This is why filters are installed by default in software that runs untrusted code or that is exposed to the network. Docker’s default profile forbids around 44 system calls out of more than 300. Browsers install a much stricter filter during rendering. The system service manager on most Linux distributions can apply a filter to any service it starts.

The overhead per call is small because the filter executes only a few dozen instructions in the kernel on every system call. The real work is writing a correct policy. A policy that is too strict breaks the program, often in a rarely used code path, such as an error handler that writes to a log file via a call nobody saw during testing. A policy that is too loose adds little. Most policies are built by recording the calls a program makes during test runs and allowing those that capture the common paths while missing the rare ones.

Later versions of Linux added another decision: the filter can hand the call to a supervisor process, which inspects it and decides, and which can perform the operation on the process’s behalf. The feature, added in Linux 5.0 in 2019, is used by container runtimes to let a container perform a few privileged operations under supervision. The manual page warns that any argument the supervisor reads from the process’s memory is subject to the time-of-check to time-of-use race condition. The supervisor has the same problem that Janus had.

Restricting What a Process Can Touch: AppArmor

We named AppArmor earlier as the path-based alternative to type enforcement. It is the mechanism that fills the gap seccomp leaves. A seccomp filter decides whether a process may call open(). AppArmor decides which files it may open.

AppArmor began as SubDomain, a product of Immunix, a company that sold a hardened Linux distribution, and was described publicly in 2000. Novell acquired Immunix in 2005 and renamed the system, and it was merged into the mainline Linux kernel in version 2.6.36 in October 2010. It is the default mandatory access control system on Ubuntu, Debian, and SUSE.

Checking the Object, Not the Name

AppArmor is a Linux Security Module (LSM). The LSM framework, added to the kernel in 2003, places hook functions at points inside kernel operations: when a file is opened, when a program is executed, when a socket is created, and at a few hundred other points. A security module registers a function for each hook, and the kernel calls it after its ordinary permission checks and before the operation proceeds. SELinux is enforced through the same hooks.

The hook for opening a file runs after the kernel has resolved the path. Symbolic links have been followed, .. components have been removed, and the kernel has in hand the actual object about to be opened. AppArmor builds the canonical name of that object from the kernel’s own data and matches it against the policy. The name is not read from process memory, so a second thread cannot change it, and the race condition that defeats an external monitor does not apply. The check is on the object the kernel is about to open, made by the kernel, at the moment it opens it.

Profiles

An AppArmor policy is a profile attached to a program, identified by the path of its executable. The profile lists the files the program may read, write, or execute, with wildcards for directories, the capabilities it may use, and the kinds of network access it may have. Any program without a profile runs unconfined. That is the practical difference from SELinux, where every process on the system is in some domain and the policy covers all of them.

A simplified profile for a web server shows the form:

/usr/sbin/nginx {
  capability net_bind_service,
  network inet stream,
  /etc/nginx/** r,
  /var/www/html/** r,
  /var/log/nginx/*.log w,
}

The server may bind a low-numbered port, accept TCP connections, read its configuration and the site’s content, and append to its logs. Anything not listed is denied.

A profile has two modes. In complain mode, the kernel logs every access the profile would have denied but allows it. In enforce mode, the kernel denies the access. Profiles are developed by running a program in complain mode, exercising it, and turning the log into rules. This has the same weakness as building a seccomp policy from test runs: the rare path is the one that gets missed.

Paths Compared With Labels

A path-based policy is easier to write and to read than a label-based one. The rule /var/www/html/** r says what it means. The equivalent SELinux rule requires that every file under that directory be labeled with the right type, that the type be assigned when files are created or copied there, and that the reader know the type names.

The price is that a path is a name, and a file can have more than one name. AppArmor checks the links a confined program creates, but a hard link made by another program or a bind mount that places a directory under a second path gives one file two paths, and the file may fall under two rules that disagree. A file renamed out of a protected directory loses the protection, and a file renamed into it gains protection it may not deserve. This is path equivalence again, on the enforcement side rather than the attack side. SELinux attaches the label to the object, so none of those operations change what the object is, but the policy is harder to write.

AppArmor and seccomp are complementary, and container runtimes apply both. The filter limits the interface to the kernel, and the profile limits the objects behind the interface. Ubuntu also uses AppArmor to decide which programs may create user namespaces, for a reason that the section on namespaces explains.

Restricting What Code Can Express: Language Runtime Sandboxes

The mechanisms so far confine a process from outside, in the kernel or in a monitor beside it. A language runtime confines a program from inside, by controlling what the program’s code can express.

A runtime that verifies code before running it, manages memory itself, and forbids pointer arithmetic prevents a program from touching anything the runtime does not hand it. The program cannot form an address, cannot read past the end of an array, and cannot make a system call. It reaches the outside world only through the library the runtime exposes, and the runtime decides what that library allows.

Three runtimes are built on this idea:

  1. The Java virtual machine, released in 1995, verifies bytecode before execution and was marketed on this property. Applets downloaded from a web page ran inside the browser without access to the user’s files.

  2. The JavaScript engine in every browser checks every array index and every type. A script reaches the outside only through the interfaces the engine exposes.

  3. WebAssembly, which browsers added in 2017, confines compiled code the same way. A module is validated before it runs, its memory is a single array that it cannot address outside of, and it can call only the functions the host passed in.

Against the five questions: a language runtime restricts memory access and system calls by construction, so the program has no way to express them. The runtime enforces it, through verification before the code runs and checks while it runs. The confined code can still reach the runtime’s library and the runtime itself. The overhead is the interpreter or the just-in-time compiler.

It fails through bugs in the runtime, which is a large program written in C or C++. A JavaScript engine is more than a million lines, and a memory corruption bug in it lets a script run native code in the process that hosts the engine. Java applets suffered a steady series of escapes, with dozens of sandbox bypasses disclosed in 2012 and 2013 alone, and browsers removed support for them.

The lesson is the one chroot taught, from the other direction. A language sandbox is a useful first boundary, but the process that runs it needs an operating system boundary underneath. The web browser is built on exactly that pairing.

Application Sandboxes and Mobile Permission Models

Desktop operating systems were designed in an era when the threat was other users. The timesharing systems of the 1970s kept users apart and gave every program a user ran the user’s full authority, since the programs were the user’s own. That model is still how a laptop works. Every application a user installs can read every file the user can read.

Phones were built differently from the start. The iPhone’s App Store opened in July 2008, and the first Android phone shipped in October 2008. Both platforms expected users to install programs from strangers by the dozen, and both made the application, rather than the user, the principal that the operating system confines.

How the Two Platforms Isolate Apps

Android assigns every installed app its own Linux user ID. The app’s files are owned by that user ID, and the ordinary Unix permission checks keep one app out of another’s files. An SELinux domain for each app enforces the same separation a second time. iOS places each app in its own container directory and runs it under a sandbox profile, shared by all apps, that confines the app to that directory and to a fixed set of system services. An app that needs more receives an entitlement, a declaration of an extra ability that Apple signs into the app when it is distributed. Since the entitlement is signed, an app cannot grant one to itself.

In both systems, the resources apps share are reached only through system services. An app does not open the camera device or the contacts database. It sends a request to the service that owns the camera or the contacts, and the service checks whether the app holds the permission before it acts. The app’s own process cannot reach the resource by any path.

Permission Models

The permission model decides how an app acquires the right to use a shared resource, and both platforms have revised it several times.

Android until version 6.0 prompted at install time. The store displayed the full list of permissions the app requested, and the user’s only choices were to install the app with all of them or not install it. Android 6.0, released in October 2015, moved to requests at the moment of first use, which the user can refuse, and to a settings page where any grant can be revoked later. iOS had prompted at first use for location from the beginning. In February 2012, the social app Path was found to be uploading users’ entire address books without asking, and iOS 6 that September extended the prompts to contacts, calendars, reminders, and photos.

The grants have become finer since. Android 10 in 2019 separated location access in the background from access while the app is in use. iOS 13 in 2019 and Android 11 in 2020 added a one-time grant that expires when the app closes. iOS 14 in 2020 and Android 12 in 2021 let the user give an app an approximate location, within a few kilometers, instead of a precise one. Android 11 also revokes the permissions of apps that have not been used for months. Revoking a permission stops future access, but it does not recover data the app has already copied.

Limits of the Model

The user’s consent is the policy, and most users approve whatever prompt appears. A permission also covers a resource, not a purpose. In December 2013, the U.S. Federal Trade Commission settled with the maker of a flashlight app that had been installed tens of millions of times and that sent its users’ locations to advertising networks. The app had requested location permission and received it, and the permission did not say what the location was for.

Apps can also talk to each other through the system’s messaging facilities, and an app that holds a permission can expose a component that lets another app use it. If a messaging app exports a component that sends a text message to any number it is given, an app without the SMS permission sends messages by having the messaging app do it. The messaging app is a confused deputy, and the sandbox is intact: the permission was checked, and it belonged to the wrong app. Android’s documentation tells developers to protect exported components with permissions of their own, and many do not.

An escape from the sandbox itself requires a bug in the kernel or in one of the system services the app is allowed to reach. The interface an app can reach is the filtered system call interface plus the messaging interface to those services, and a full compromise of a phone from an installed app chains bugs in those interfaces.

Sandboxes on the Desktop

Both desktop platforms added opt-in sandboxes after the phones did. Apple’s App Sandbox arrived in 2011, and apps sold through the Mac App Store have been required to use it since June 2012. Windows 8 in 2012 introduced a restricted process environment for apps from its store. In both cases, software distributed outside the store is not required to use the sandbox, and most of it does not. This is why a desktop application still runs with its user’s full authority while an app on the same person’s phone does not.

Combining Mechanisms: The Browser

A web browser runs code from every site its user visits, and some of that code is hostile. The browser is the one program on a desktop that confines what it runs, and it does so with every mechanism described so far, layered.

Chrome shipped in September 2008 with an architecture that has since become universal. A browser process holds the user’s authority: it owns the files on disk, the network connections, the cookies, and the saved passwords. Renderer processes parse HTML, lay out pages, and run JavaScript, and they hold very little. A renderer that needs a network request, a file, or a cookie sends a message to the browser process, which decides whether to do it. This is the privilege separation of OpenSSH, with a renderer in the role of the unprivileged child, applied to a program that creates dozens of children.

Each renderer runs inside an operating system sandbox built from the mechanisms above. On Linux, the renderer runs in its own process ID and network namespaces and under a seccomp-BPF filter that forbids opening files and creating sockets. On Windows, it runs with a restricted token that cannot access the user’s files. On macOS, it runs under a system sandbox profile. A compromised renderer can compute, and it can send messages to the browser process. It cannot open a file.

Two Boundaries, and Why Both Are Needed

The JavaScript engine is the language runtime sandbox, and it is the first boundary. A script cannot name a file or form a pointer to raw memory.

A memory corruption bug in the engine lets a script run native code inside the renderer process. The operating system sandbox is the second boundary for that case. A full browser exploit is a chain of two bugs: a renderer bug that gives the attacker native code, and a sandbox escape that gets that code out. Browser vendors’ bug bounty programs pay more for the second than for the first.

The overhead of the design is memory for many processes and latency for every request that crosses between them. Since 2018, Chrome has gone further and places each site in its own renderer, so that a renderer compromised by one site holds data from that site only. The change followed the disclosure of the Spectre attacks in January 2018, which showed that code in a process could read memory anywhere in that process, including the data of other sites sharing it. Google reported that the change raised the browser’s total memory use by 10 to 13 percent.

Operating System Isolation: Namespaces, Control Groups, and Capabilities

The mechanisms so far confine one process. Most services are several cooperating processes, and confining them together requires isolating the view of the system they share. Linux provides three kernel features for this. Namespaces control what a group of processes can see, control groups limit how much they can consume, and capabilities divide root’s privileges into separate pieces. These features were built over roughly a decade, by different people for different reasons, and containers combine all three.

Namespaces

A namespace gives a group of processes their own copy of one part of the kernel’s global state. Processes in the same namespace share that view, and processes in different namespaces see different views. The idea comes from Plan 9, the Bell Labs system released in 1992, which gave every process its own namespace. Linux added them one kernel subsystem at a time, beginning with mount namespaces in 2002 and ending, for now, with a time namespace in 2020:

Namespace Isolates Effect
Mount Mounted filesystems Each namespace has its own set of mounts, including its own root.
PID Process IDs The first process in a new namespace is PID 1. Processes cannot see or signal processes outside their namespace, but the host sees all of them.
Network Interfaces, addresses, routing tables, ports A new namespace starts with only a loopback interface. Each namespace has its own port 80.
UTS Hostname and domain name A process can have its own hostname.
IPC System V and POSIX interprocess communication objects Shared memory and message queues are visible only within the namespace.
User User and group IDs IDs inside the namespace map to different IDs outside it.
Cgroup The control group hierarchy Processes see only their own subtree of the hierarchy.
Time The monotonic and boot-time clocks A namespace can have its own offset, so a restored process sees consistent time.

A process creates a namespace with unshare(), which moves the calling process into a new namespace, or with clone(), which creates a child in one. Creating most kinds of namespace requires privilege. A new network namespace contains only the loopback interface. The administrator connects it to the world by creating a pair of virtual network interfaces that behave like the two ends of a cable and placing one end in each namespace.

Namespaces hide and partition. They do not restrict what a process may do with what it can see. A process with the privilege to mount filesystems can mount them inside its mount namespace, and a process with the privilege to configure network interfaces can configure the ones in its network namespace. Namespaces change what a privileged system call affects. They do not forbid the call.

User Namespaces

The user namespace, completed in Linux 3.8 in 2013, is the one that changed who could use the others. Inside a user namespace, a process can be user ID 0 and hold every capability, while outside it the same process is an ordinary unprivileged user. The kernel maps the IDs. Since the process is root in its own namespace, it can create other namespaces there, which lets an ordinary user build a private mount tree or network stack without any help from an administrator.

The risk is that user namespaces let ordinary users reach kernel code that previously required root, and that code was written on the assumption that only root could reach it. In January 2022, a heap overflow was disclosed in the kernel’s code for parsing filesystem mount parameters. Reaching the bug required a capability that only root normally holds, and an unprivileged process obtained it by creating a user namespace. The bug was used to escape containers on Kubernetes clusters, which at the time did not apply a system call filter to a container by default, so the one call that creates the namespace was available. Ubuntu, since version 23.10 in 2023, uses AppArmor to permit unprivileged user namespaces only to programs whose profiles allow them. Its documentation gives the reason directly: user namespaces “have been repeatedly used to exploit kernel vulnerabilities.”

Control Groups

A control group (cgroup) limits how much of a resource a group of processes may consume. The feature was written at Google in 2006, under the name “process containers,” for the cluster management system that schedules Google’s own workloads. It was merged into Linux 2.6.24 in January 2008 under its current name. The interface is a filesystem, mounted at /sys/fs/cgroup, in which each directory is a group and each file in the directory is a limit or a measurement.

Groups form a hierarchy, and a subgroup cannot exceed its parent’s limit. A controller for each kind of resource enforces the limit. The CPU controller gives a group a share of processor time or a hard quota. The memory controller caps the group’s memory, and a group that exceeds its cap has one of its own processes killed, with every other group unaffected. The I/O controller limits disk bandwidth. The process-number controller caps the number of processes in the group, which ends the fork bomb as an attack. A group limited to a hundred processes can call fork() in a loop and harm only itself. The system service manager on most distributions places every service it starts in its own group, so the limits apply to services whether or not anyone configures a container.

Capabilities

Unix had two levels of privilege. A process whose effective user ID was zero bypassed every permission check, and every other process was subject to all of them. A program that needed one privileged operation, such as ping, which needs to send a raw network packet, had to run as root for everything.

Capabilities, added in Linux 2.2 in 1999, divide root’s authority into about forty separately grantable privileges. A process holds a set of them, and the kernel checks for the specific capability before each privileged operation rather than checking for user ID 0. A process running as root with its capabilities removed cannot perform privileged operations, and a process running as an ordinary user with one capability granted can perform that one. These capabilities are unrelated to the capability lists of access control, which are a way of recording who may access an object.

A few of them show the granularity:

The last entry shows how hard it is to split privilege cleanly. The operations that did not fit any other category were collected under one capability, and that capability grew until it was most of root.

A capability can be attached to an executable file as an extended attribute, so that a program gains it when it starts without being setuid root. Modern distributions ship ping this way, so a bug in ping no longer gives an attacker root. A process can also drop capabilities it no longer needs. A web server starts with the capability to bind port 80, binds it, and drops the capability before it reads its first request. If the server is compromised afterward, the attacker cannot bind another port, and the server cannot get the capability back.

Containers

A container is a process, or a tree of processes, started in new namespaces, placed in control groups, given a reduced set of capabilities, and run under a seccomp filter and an AppArmor or SELinux profile, with its root filesystem built from an image. A container runtime is the program that performs that setup. The kernel itself does not know what a container is. It has namespaces, control groups, capabilities, filters, and profiles, and a container is the name for using all of them at once. Since the container uses the host’s kernel, a Linux container runs only on a Linux kernel. Running one on a Mac or Windows laptop involves a Linux virtual machine underneath.

Containers were not built for security. The kernel features were written for resource management, and the runtimes were written for packaging and deployment. The problem they solved was that installing several services on one machine was painful. Each service had its own dependencies, updating a library for one service broke another, and moving a service to a different machine meant reinstalling and reconfiguring it. Running every service on its own machine was expensive, and running each in its own virtual machine meant a full operating system per service.

A container packages a service with every library and configuration file it needs into one image that runs the same way anywhere, and it runs as an ordinary process. LXC, the first widely used Linux runtime, appeared in 2008. Docker, released in March 2013, made the image format and the commands easy enough that containers became the standard way to deploy a service. Isolation was a side effect.

Images

An image is a snapshot of a root filesystem: a minimal Linux distribution or just an application’s files, with its libraries and configuration. Images are built in layers, and each layer records only the files that changed from the layer below it. A copy-on-write filesystem stacks the layers. A hundred containers started from the same image share one copy of its files on disk and in memory, and each container writes its changes to a private top layer. Deleting a file in a later layer hides it without removing it from the earlier layer, so a credential copied into an image during a build and deleted afterward is still in the image. Images are stored in registries, public and private, and a host fetches an image from a registry by name.

Each container’s network namespace has its own port numbers, so three web servers in three containers can each listen on port 80. The host maps external ports onto them and routes traffic to each container’s virtual interface.

Benefits

Most of the security benefits of containers come from defaults. A container image for a service typically contains that service and little else, so there are fewer programs for an attacker to use after a compromise. The runtime drops most capabilities, installs a system call filter, and applies an AppArmor or SELinux profile without the administrator asking for any of it. Services are kept apart from each other, so a compromised web server cannot read the database’s files. The environment a service runs in is written down in the image, so it can be inspected and reproduced. A fix for a vulnerable library ships by rebuilding the image rather than by patching running machines one at a time.

The overhead is close to zero, because a container is a process. CPU and memory use are those of the service itself, and a container starts in a fraction of a second. The overheads are elsewhere: storing and distributing images, and running the systems that schedule containers across machines.

Risks

A container shares the host’s kernel with every other container and with the host, and that fact produces every serious risk:

Containers separate cooperating services well, and a compromised service inside a well-configured container has far less to reach than the same service on a bare host. They are not, on their own, a boundary for hostile code. The confined code has the entire system call interface in front of it, minus the few dozen calls the filter removes, and one kernel bug ends the separation.

Reducing What an Escape Reaches

Two developments reduce the damage of an escape. Rootless runtimes run the runtime itself, and every container, as an ordinary user inside a user namespace, so that an escape lands as that user rather than as root. Cloud providers run each customer’s containers inside virtual machines that belong to that customer, so an escape from a container reaches only the customer’s own virtual machine.

The systems that schedule containers across a fleet of machines add a risk of their own. An orchestrator such as Kubernetes, released by Google in 2014 from its internal cluster management system, has a control plane that can start any container on any machine, and that control plane is a high-value target. In February 2018, Tesla’s Kubernetes administrative console was found exposed to the Internet without a password, and it was being used to run cryptocurrency miners on the company’s cloud account.

Virtual Machines

A virtual machine moves the boundary down one level. Instead of one kernel presenting a filtered view of itself to several groups of processes, a hypervisor, also called a virtual machine monitor, presents a complete computer to several operating systems, each with its own kernel. The confined code is an entire operating system, and the interface it can reach is virtual hardware.

History

Virtual machines are older than Unix. In the mid-1960s, IBM built CP-40 and then CP-67 so that several people could develop and test operating systems on one shared mainframe, each on what appeared to be a private machine. The design became a product as VM/370 in 1972. The idea faded on minicomputers and personal computers, which were cheap enough that an operating system could own the machine.

It returned on commodity servers with VMware, whose first product shipped in 1999. The motivation was server consolidation. Data centers were full of servers that each ran one service, since two services on one machine could interfere with each other, and most of those servers sat idle most of the time. A hypervisor let ten of them become ten virtual machines on one physical server. The same separation later made virtual machines the unit of isolation between customers in cloud computing.

Trap and Emulate

The hypervisor runs beneath the guest operating systems and controls the real hardware. The technique it relies on is trap and emulate. The guest kernel runs with reduced privilege, and ordinary instructions run directly on the processor at full speed. An instruction that would change the state of the machine, such as one that reconfigures memory mapping or disables interrupts, traps to the hypervisor. The hypervisor performs an equivalent operation on the guest’s virtual machine and resumes the guest. In 1974, Gerald Popek and Robert Goldberg showed that this works when every instruction that depends on privilege also traps when executed without it.

The x86 did not meet that condition. Several of its instructions behaved differently at user privilege without trapping, so early hypervisors worked around the problem in software. Intel and AMD added hardware support in 2005 and 2006: a processor mode below the kernel in which every sensitive instruction traps. With hardware support, trap and emulate works as designed, and it has been the standard since.

How a Virtual Machine Runs

With hardware support, the guest kernel runs directly on the processor in a deprivileged mode. When it executes a sensitive instruction, takes a page fault the hypervisor needs to see, or receives an interrupt, the processor performs a VM exit. It saves the guest’s state, loads the hypervisor’s, and transfers control. The hypervisor handles the event and performs a VM entry back into the guest. The overhead of a round trip is on the order of a thousand cycles, and the performance of a virtual machine is largely a question of how many exits its workload causes. A system call made by a guest application does not cause an exit. It enters the guest’s own kernel, as it would on a real machine. This is the central difference from a container, whose system calls enter the shared host kernel.

Memory is controlled by a second level of address translation. The guest kernel manages its page tables as usual, mapping guest virtual addresses to what it believes are physical addresses. The hypervisor maintains a second table, which the hardware applies to every access, that maps those guest physical addresses to real ones. A guest can reach only memory that appears in its second-level table, and the hypervisor controls that table. Before processors supported this, hypervisors maintained shadow copies of every guest page table and trapped every change to them, which was one of the largest sources of overhead.

Devices are handled in one of three ways. An emulated device is a program in the hypervisor that behaves like a specific piece of hardware, such as a particular model of network card, so that an unmodified guest driver works. A paravirtualized device is a simplified interface that a guest driver written for virtualization uses, which is faster because it does not pretend to be hardware. A passed-through device is a real device given to one guest. Passing a device through requires an I/O memory management unit to translate the device’s own memory accesses, since otherwise a device that reads and writes memory directly could reach any memory on the host.

A virtual machine does not emulate the processor. With hardware support, guest instructions run on the real processor, and only the devices are emulated. That is why CPU-bound work in a virtual machine runs at nearly native speed, while I/O through an emulated device is noticeably slower.

Type 1 and Type 2

A Type 1 hypervisor runs directly on the hardware. VMware’s ESXi, Microsoft’s Hyper-V, and Xen are of this kind, and so is KVM, which turns the Linux kernel into one. Data centers and clouds use them. Even a Type 1 hypervisor relies on a privileged management environment and a large set of device drivers, so the hypervisor proper is not the whole trusted computing base. A Type 2 hypervisor runs as an application under an ordinary operating system and uses that system’s drivers for the hardware. VirtualBox, VMware Workstation, and Parallels are of this kind, and they are what a developer uses to run Linux on a laptop. The distinction is about where the hypervisor sits in the trusted computing base. A Type 2 hypervisor is only as secure as the host operating system beneath it.

Benefits

Each guest has its own kernel, so a kernel bug in one guest is confined to that guest. The interface exposed to the guest is virtual hardware plus a small set of hypervisor calls, and that interface is far narrower than the system call interface a container sees. A container can attempt more than 300 system calls. A guest can attempt to touch a few dozen emulated devices and registers.

The state of a virtual machine can be saved as a file. Its memory and disk can be saved as a snapshot, restored to the moment the snapshot was taken, cloned, or moved to another physical machine while running. Malware analysts run suspicious programs in a virtual machine and reset it afterward. Any operating system can be a guest, which is why a Linux host can run Windows and a Windows host can run Linux.

Overheads

CPU-bound work runs at close to native speed, except for the exits. I/O carries more overhead, especially through emulated devices, where every packet or disk block passes through the hypervisor’s emulation code. Every guest runs its own kernel and its own copy of the system’s services, which is hundreds of megabytes of memory per guest before the application starts. A guest boots in seconds rather than the fraction of a second a container needs. And every guest is one more operating system to patch.

Risks

The code behind the virtual hardware interface has bugs of its own:

Containers and virtual machines are not alternatives. Cloud providers run each customer’s containers inside that customer’s virtual machines, so that the kernel the containers share belongs to one customer, and the boundary between customers is the hypervisor.

Virtualization-Based Security and Its Limits

A hypervisor that starts before the operating system can protect parts of that operating system from its own kernel. This is the idea behind virtualization-based security (VBS), which Windows has included since Windows 10 in 2015 and which Windows 11 enables by default on new machines.

The design places two environments under one hypervisor. The normal environment runs the Windows kernel, its drivers, and every application. The secure environment runs a small secure kernel and a few services, and the hypervisor’s second-level page tables prevent the normal kernel from reading or writing the secure environment’s memory. The normal kernel still runs with kernel privilege and still owns the hardware, but there are now pages of memory on the machine that it cannot touch.

Two uses account for most of the benefit. The first is code integrity. The secure environment decides which pages of kernel memory may be executable, and it marks a page executable only after verifying the signature on the code in it. A compromised kernel cannot make its own injected code executable, since the page tables that would allow it are controlled from the other side. The second is credential protection. The secrets that Windows uses to authenticate a user to other machines are kept in the secure environment, so malware with full control of the normal kernel cannot read them out of memory. Reading them out of memory was the standard way to steal credentials for a decade.

The Limits

The protection is narrower than it sounds:

Every layer of containment depends on the layer beneath it. A kernel protects processes from each other until the kernel is compromised, a hypervisor protects kernels until the hypervisor is, and code that runs before the hypervisor is beneath all of it.

Comparing the Mechanisms

The five questions from the start apply to every mechanism described above, and the table below sets the five answers side by side:

Mechanism What it restricts Who enforces it What the confined code can still reach What overhead it adds How it fails
chroot Which files a process can name Kernel path resolution Every system call, the network, all resources, and files through devices or descriptors Copying a tree of files, with almost zero runtime overhead Root inside escapes in three system calls, and names are not operations
seccomp-BPF Which system calls, with integer arguments, a process can make Kernel, on every call The allowed calls, with any file name A few dozen instructions per call, and writing a correct policy Too loose adds little, too strict breaks the program, and a bug in an allowed call is reachable
AppArmor Which objects a program can open, execute, or connect to Kernel, in LSM hooks after path resolution Every allowed object, through every system call Writing and maintaining a profile per program A file with two names, an unconfined program, or a kernel bug
Language runtime What the program’s code can express: memory access and system calls are unavailable by construction The runtime, by verification before the code runs and checks while it runs The runtime’s library and the runtime itself An interpreter or a just-in-time compiler A memory bug in the runtime, which is millions of lines of C or C++
Mobile app sandbox Which files and which system services an app can reach Kernel, plus permission checks in system services The filtered system call interface and the messaging interface to services Prompts the user does not read, and apps that cannot cooperate User consent, confused deputies among apps, or a kernel or service bug
Container Namespaces, resource limits, capabilities, system calls, and files, together Kernel, configured by the runtime The shared kernel’s system call interface, minus the filter Close to zero at runtime, plus images and orchestration A kernel bug, a runtime bug, misconfiguration, or a bad image
Virtual machine An entire operating system, confined to virtual hardware Hypervisor and processor Virtual devices and a few hypervisor calls A kernel per guest, I/O through emulation, and seconds to boot A device-emulation bug, a hypervisor or management-service bug, or shared hardware

The strength of a boundary depends on the size of the interface the confined code can reach, the correctness of the layer that enforces the boundary, and the correctness of the configuration. A virtual machine has the smallest interface and the strongest enforcement, and it adds the most overhead. A system call filter adds almost none and reduces the interface without closing it. Real systems do not choose one. A rendering process in a browser runs under a language-level sandbox, inside an operating system sandbox, inside a container, inside a virtual machine, on a shared physical machine. An attacker who wants the physical machine has to get through all of them in order.

Every one of these mechanisms assumes that the layer beneath it is honest. A system call filter trusts the kernel that runs it. A container trusts the kernel it shares. A virtual machine trusts the hypervisor, and the hypervisor trusts the firmware that loaded it and the processor it runs on. Some layer always has to be trusted. What a design chooses is how much code sits beneath the boundary and how carefully that code was written.