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
| Architecture | Number | ABI | Entry point |
|---|---|---|---|
| x86 (i386) | 61 | i386 | sys_chroot |
| x64 (x86_64) | 161 | common | sys_chroot |
| ARM64 (aarch64) | 51 | — | sys_chroot |
Kernel history
Introduced in Linux 1.0.
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.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") = 0chroot() 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.