Skip to content
/linux-syscalls

Filesystems & Mounts · Section 2

chroot(2)

Change the apparent root directory for the calling process and its children.

Signature

#include <unistd.h>

int chroot(const char * path);
path
Directory that will become the new apparent root. Must exist and be a directory.

Description

chroot() changes the apparent root directory for the calling process — paths starting with / are interpreted as starting under path, and absolute lookups cannot ascend above it (a final /../../../ resolves to path itself). Children inherit the chrooted view. The call requires CAP_SYS_CHROOT in the caller's user namespace and is intentionally non-recoverable in many sense — once chrooted, the process cannot easily un-chroot (the classic 'chroot break' trick relies on holding a directory fd opened before chroot() and using fchdir to climb out, which is why chroot is NOT a security boundary by itself). Containers do not use chroot() for isolation; they use mount namespaces plus pivot_root() to atomically swap the entire root. chroot() survives mostly in legacy daemons (bind, vsftpd, sftp-server) and as a step in package-building chroots.

Architecture mapping

ArchitectureNumberABIEntry point
x86 (i386)61i386sys_chroot
x64 (x86_64)161commonsys_chroot
ARM64 (aarch64)51sys_chroot

Kernel history

Introduced in Linux 1.0.

  1. 1.0

    chroot() has been part of Linux since 1.0 — inherited from V7 Unix. The lack of recursion in the security model has been the same since then.

  2. 2.6.16

    pivot_root() was added so container runtimes can atomically swap the entire root filesystem (typically combined with CLONE_NEWNS to make the change local to a mount namespace). It is materially stronger than chroot() because it moves the old root out of the way and can be paired with umount() to make it unreachable.

seccomp & containers

Docker default profile

Blocked

Podman default profile

Blocked

chroot() is BLOCKED by default in Docker and Podman seccomp profiles, partly because workloads almost never need it (containers already provide a root view) and partly because misusing it can confuse the runtime. Permitting chroot() inside a container is an unusual choice — it allows the workload to create nested chroots, which is rarely useful and has occasionally been the basis of escape patterns when combined with capability misconfigurations.

libseccomp

// chroot is NOT on the Docker default allow-list; explicitly deny for clarity
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(chroot), 0);

strace example

$ strace -e chroot sudo chroot /opt/myroot ls /
chroot("/opt/myroot")                   = 0

chroot() appears once per process when used. Following its target with -y on subsequent open() calls reveals the chrooted view. Combined with -e file, you can audit exactly what files the chrooted process touches — useful when minimising a chroot's contents.

Security & observability

chroot() is widely misunderstood as a security boundary; it is not. A process inside a chroot that retains any of: CAP_SYS_CHROOT, an open directory fd from outside the chroot, ptrace on a process outside, mknod permission to create /dev/sda — can trivially escape. The classic escape: fchdir(saved_fd) → chdir('../') × 100 → chroot('.') → /bin/sh. For real isolation, use mount namespaces + pivot_root + a read-only bind-mount, or Landlock for path-level restrictions. eBPF tracepoint sys_enter_chroot is rare in production traffic; an unexpected chroot() inside a container that wasn't already in one is incident-worthy. Set-UID daemons that chroot() then drop privileges (the BIND pattern) are the legitimate non-container use.

Errors

EACCES
Search permission denied on a component of path.
EFAULT
EIO
ELOOP
Too many symbolic links.
ENAMETOOLONG
ENOENT
path does not exist.
ENOMEM
ENOTDIR
path is not a directory.
EPERM
Caller lacks CAP_SYS_CHROOT in its user namespace.

Related syscalls