Virtualization · Sandboxing

Escaping a Chroot Through Retained Directory Descriptors

Why changing a process root does not confine a process that holds an open descriptor above it, how the classic escapes work, and what pivot_root and mount namespaces do differently.

chroot changes one field of the process’s filesystem context: the root of path resolution. It does not change the working directory, does not close file descriptors, does not restrict chdir, and does not prevent the process from holding references to directories outside the new root. The manual page states the limitation in a single sentence, that the call is not a security mechanism, and the reason is structural rather than a missing check.

What the call actually changes

A process’s filesystem context contains a root directory and a current working directory, both of which are (mount, dentry) references, not strings. chroot("/srv/jail") replaces the root reference. Every subsequent path resolution that begins with / starts at the new root, and .. at the root stays at the root rather than ascending.

Nothing else moves. The working directory keeps its old reference, so a process that chroots while its working directory is outside the new root retains a reference to the outside and can resolve relative paths from it. fchdir on a retained descriptor restores any directory as the working directory. File descriptors to files outside the new root keep working, because the descriptor refers to an open file description, and no path resolution is involved in reading or writing through it.

Two syscalls therefore suffice to leave, and both are ordinary operations that a jail cannot easily forbid without breaking the payload:

  • mkdir a directory inside the jail, then chroot into it. The process’s root is now a directory whose .., resolved before the second chroot, points outside. The classic form requires the process to still hold privilege to call chroot a second time, which a jailed root process does unless capabilities were dropped.
  • Open a descriptor to a directory outside the jail before the chroot, then fchdir to it after. The working directory is now outside the root, and a subsequent chroot(".") sets the root to that outside directory. This one needs no capability beyond what chroot itself requires, which is CAP_SYS_CHROOT.

The second is the one that matters for sandboxes, because it does not depend on the jailed program regaining privilege; it depends only on what descriptors it inherited or was able to open before confinement.

Descriptor inheritance is the actual boundary

The design constraint behind every chroot escape is that a file descriptor is a capability in the original sense: possession is authorization, and no path check applies to it. A sandbox that runs chroot and then execves a payload inherits every descriptor the parent left open across the exec, unless they were marked close-on-exec. Directory descriptors opened before the chroot are the dangerous ones, and they are common: a runtime that opened the host’s /proc, /sys, or its own working directory before confining itself has handed the payload a reference that no chroot can revoke.

The correct ordering makes the descriptor set part of the boundary rather than an accident:

  1. Open the new root and any descriptors the payload legitimately needs.
  2. chdir into the new root.
  3. Close every descriptor not explicitly intended, or mark it O_CLOEXEC and rely on the exec to close it.
  4. chroot(".").
  5. Optionally chdir("/") so the working directory is inside the root as well.
  6. execve the payload.

Step 3 is the one that is usually incomplete. O_CLOEXEC on descriptors the sandbox opens itself is straightforward; descriptors inherited from whatever launched the sandbox are not, and the only reliable way to enumerate them is to walk /proc/self/fd and close everything above 2 before the exec. That walk itself requires /proc, which must be mounted before the confinement and then hidden by the chroot, not after.

Why the escape works even with a second chroot denied

Suppose the sandbox denies chroot to the payload with a seccomp filter returning EPERM, as described in What a Seccomp Filter Can See of a Syscall. The retained-descriptor escape still yields a working directory outside the jail, and a process whose working directory is outside its root can still open files by relative path: open("../../etc/shadow") resolves relative to the working directory, and the .. components ascend through the real hierarchy because the working directory is a reference into it, not a path through the root. The root only constrains resolution that passes through it; a relative path from an outside working directory never does.

This is why denying chroot is not sufficient and why the escape is usually described as being about the working directory rather than about the root. The confinement is a pair of references, and an attacker who controls either one can often reach outside.

fchdir on an outside descriptor is the general form. Denying fchdir closes it, but fchdir is used by ordinary code that saves and restores a directory position, so a filter that denies it has to be sure the payload never needs it. Denying openat with a directory descriptor in the first argument closes another route, and that is what a path-based mechanism does properly rather than by enumeration.

pivot_root and the mount namespace

pivot_root(new_root, put_old) was designed for the case chroot handles badly. It requires new_root and put_old to be directories in the same mount namespace, requires new_root to be a mount point, and requires that new_root is not the current root and that put_old is underneath new_root. It then makes new_root the root mount of the namespace and stacks the old root on put_old, after which the old root can be unmounted.

The operation is atomic with respect to the namespace: there is no instant in which the process has a root reference that is not a mount point, and the old root is reachable only through the designated directory until it is unmounted. Because it manipulates the mount table rather than a per-process root field, it is only meaningful with CLONE_NEWNS, and it requires CAP_SYS_ADMIN in the mount namespace’s owning user namespace.

The remaining escape is the same one: descriptors. pivot_root does not close anything. A descriptor to the old root held across the operation still refers to the old root’s dentry, and a process that can fchdir to it and then open relative paths can still read the host filesystem. What pivot_root adds is that the old root can be genuinely unmounted, so if the descriptor is closed and the mount is detached, there is no object left to reference. chroot cannot do that: it leaves the old root mounted and reachable by anything that already holds a reference.

That is the substantive difference. chroot renames the origin of path resolution; pivot_root rearranges the mount tree so that the old tree can be removed. Only the second gives a guarantee that survives an audit of the descriptor table.

openat2 as a way to make the check structural

openat2 with RESOLVE_IN_ROOT treats the descriptor passed as dirfd as a root for the resolution, and RESOLVE_BENEATH refuses any path that would escape it, including through absolute paths and ... Both are enforced during the walk, component by component, after each component is resolved. A process that opens with these flags and a descriptor to the jail cannot escape via .. or via a symlink, because the resolution refuses rather than the caller checking afterwards.

This is a per-call guarantee, not a per-process one. It does not stop the process from opening a descriptor by other means, and it does not retroactively fix descriptors already open. It is the right primitive for the code that opens files on behalf of the sandbox, and it is the wrong primitive for confining a payload that calls open directly; for that, Landlock, described in Landlock Rulesets: Path Hierarchies and Access Masks, enforces on the resolved object without requiring the payload to cooperate.

What a chroot is still good for

A chroot remains a useful correctness tool. It makes a program see a coherent filesystem tree for dynamic linking, configuration lookup, and path assumptions, and it prevents accidental writes to the host by a program that constructs absolute paths from a configuration file. As a boundary against a program that is trying to escape, it fails, and the failure is not subtle: it requires only that the program hold one descriptor to a directory outside, which is the normal state of any process that has not deliberately scrubbed its descriptor table.

The operational rule that follows is to treat the descriptor table as part of the sandbox definition and to enumerate it explicitly, because every mechanism discussed here, including chroot, pivot_root, mount namespaces, Landlock, and seccomp path filtering, is bypassed by a descriptor that was opened before the mechanism applied. A sandbox is only as tight as the set of references the confined process holds at the moment confinement takes effect.

References

  1. chroot(2) — Linux manual page — man-pages project
  2. pivot_root(2) — Linux manual page — man-pages project
  3. openat2(2) — Linux manual page — man-pages project