Operating Systems · Isolation and Namespaces

Chroot Confines Path Resolution, Not the Process

What the chroot call changes in the kernel, why a process that keeps a directory descriptor can walk out of the new root, and which guarantees the call does and does not provide.

A process calls chroot("/srv/jail"), drops privileges, and execs a shell. From inside, /etc/passwd names a file in the jail, and ls / shows only what the jail contains. The confinement looks total. It is not: the same process, holding nothing more than a descriptor or a working directory it had before the call, can step back into the host filesystem and read anything the kernel will let it read. Understanding why requires separating two things that the word “root” conflates — the directory a process resolves absolute paths against, and the set of objects the process can name at all.

The kernel stores one extra pointer per process

Every process has a fs_struct containing two struct path values: the root and the current working directory. chroot replaces the root with the resolved path of its argument and does nothing else. It does not change the working directory, does not touch open file descriptors, does not change the mount namespace, and does not restrict which syscalls the process may issue.

Path resolution consults that root in a narrow way. When a pathname begins with /, the walk starts at fs->root instead of at the mount namespace’s root. When the walk encounters .. and the current position is the process root, the walk stays put rather than ascending. Every other rule of name resolution is unchanged: symlinks are followed, mounts below the root are traversed normally, and a path can still reach any file whose name is reachable by walking down from the new root. The call requires CAP_SYS_CHROOT, capability 18 in the kernel’s numbering, and fails with EPERM without it.

The comparison that stops the ascent tests the mount as well as the dentry, and the pair matters. A directory can appear at several positions in the tree when it is bind-mounted, and the same dentry then belongs to more than one mount. If the walk stopped whenever the current dentry matched the root dentry, a jail whose root was a bind mount of a host directory would let .. continue from the other appearance of that directory, ascending through a mount the jail never contained. Comparing (mount, dentry) makes the stop condition positional rather than nominal, which is the property the confinement actually needs.

The restriction is therefore a restriction on the starting point of a downward walk, applied at the point where the VFS decides what / means. It is not a property of the process that other kernel interfaces respect. openat with a directory descriptor, fchdir, statx on an already-open file, and every operation on an inherited descriptor bypass the question entirely, because they never perform a walk from /.

Leaving through the working directory

The oldest escape needs no descriptor at all, only a working directory that was outside the jail when the call was made.

/* cwd is /tmp/outside, a real directory on the host */
mkdir("/tmp/outside/jail", 0755);
chroot("/tmp/outside/jail");   /* root := jail, cwd := outside */
chdir("..");                   /* walk ascends: cwd was never inside */
chdir("..");
chroot(".");                   /* root := a host directory above the jail */

The third line is the whole trick. After chroot, the process root is the jail but the working directory is still /tmp/outside, a path that is not inside the jail at all. The rule that .. stops at the process root only applies when the walk is at the process root; here it is not, so the two chdir("..") calls climb the host tree. chroot(".") then installs the host directory as the new root, and the jail is gone. Repeating the ascent reaches /.

Linux does not reject a chroot whose new root does not contain the caller’s working directory, and it does not silently relocate the working directory into the new root. A check that the new root be an ancestor of the working directory would close this path, and the reason it is absent is that it would break the call’s other use: re-anchoring path resolution for a process that deliberately keeps a directory or descriptor elsewhere.

Descriptors are not revoked

A related escape uses a directory descriptor opened before the call. File descriptors are references to struct file objects, and a struct file for a directory carries the struct path of that directory. Nothing in chroot walks the descriptor table looking for directories that fall outside the new root, and even if it did, the descriptor could have been passed to a cooperating process beforehand.

int fd = open("/", O_RDONLY | O_DIRECTORY);   /* host root */
chroot("/srv/jail");
fchdir(fd);                                    /* cwd := host root */
chroot(".");                                   /* root := host root */

Both escapes share a shape: they change the process root to a directory the process could already name, and the kernel permits that because chroot accepts any directory the caller can resolve and has CAP_SYS_CHROOT over. Once a process has the capability, it can re-root itself as often as it likes. The capability is the real boundary; chroot is the mechanism.

This is why the call is documented as unsuitable for security isolation. A jail built from chroot alone confines a process only if that process is fully trusted to remain inside, which is a strange assumption for a sandbox.

Where the boundary actually sits

A mount namespace does not close the descriptor escape, and assuming it does is a common mistake. Entering a namespace copies the parent’s mounts; it does not revoke descriptors, and a descriptor pinned before the copy still refers to the same object afterwards. What the namespace does provide is the ability to make the escape pointless: after pivot_root the old root is a mount that can be lazily detached, and once detached, the tree behind it has no mount in the namespace at all, so the descriptor names an object that no walk can reach. That is the mechanism described in Assembling a Container Root with pivot_root and Overlay Mounts, and it is why a container runtime closes every descriptor into the old root before the pivot rather than relying on the namespace alone.

Capabilities matter for the same reason. In a user namespace the process may hold CAP_SYS_CHROOT over its own namespace while holding nothing over the initial one, so a re-root inside the container is harmless. That mechanism is the subject of Capabilities and the Decomposition of Root Privilege.

One more boundary deserves mention: chroot does not change the mount namespace, so mounts performed after the call by a process with CAP_SYS_ADMIN still attach to the shared mount table, and a bind mount of a host directory placed anywhere under the new root becomes visible inside the jail. The MS_RDONLY and MS_NOSUID flags on the jail’s filesystem are a partial defence against the second-order problem — a setuid binary inside a writable jail — but not against the mount itself.

The remaining uses

The call is still the right tool when the goal is name resolution rather than confinement. Package managers use it to run maintainer scripts against the target tree: the script’s absolute paths must mean “inside the staged root”, and nothing else needs to be true. Boot loaders and installers use it for the same reason. Its relation to pivot_root is that of a weaker substitute rather than a foundation: pivot_root moves the namespace’s root mount and is what a container uses, while chroot moves only the process root inside whatever mounts already exist, and the two are chosen for different jobs rather than layered one on the other.

Its failure modes are correspondingly narrow. chroot fails with ENOENT if the argument does not resolve, ENOTDIR if it is not a directory, EPERM without the capability, and EACCES if a component of the path is not searchable. PATH_MAX of 4096 bytes and the limit of 40 symlink traversals in a single resolution still apply, so a deeply nested jail path can fail for reasons unrelated to permissions. It never fails because the operation would be insecure, because it has no notion of insecurity.

For a sandbox, the honest summary is that chroot provides a namespace for the / prefix and nothing more. Every property a sandbox needs — that the process cannot see host mounts, cannot signal host processes, cannot consume unlimited memory, cannot invoke arbitrary syscalls — is supplied by a different mechanism, and each of those mechanisms has its own set of holes to reason about.

References

  1. chroot(2) — Linux manual page — man7.org
  2. path_resolution(7) — Linux manual page — man7.org
  3. Understanding the Linux Kernel, 3rd edition — Daniel P. Bovet and Marco Cesati, O'Reilly, 2005