CWE-243: Creation of chroot Jail Without Changing Working Directory - C
Overview
On Unix-like systems, chroot() remaps / to a new directory for the calling process, but does nothing to the process's current working directory. A process that calls chroot() and continues using relative paths without first calling chdir("/") can still reach anything outside the jail that its underlying file permissions allow - the jail exists in name only until the working directory is moved into it too.
Common Vulnerable Patterns
chroot() Without a Following chdir()
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
// VULNERABLE - chroot() without chdir()
void setup_jail_bad(void) {
if (chroot("/var/jail") != 0) {
perror("chroot");
exit(1);
}
// BUG: working directory is still outside the jail.
// A relative path such as open("../../../etc/shadow", O_RDONLY)
// still resolves against the real filesystem root, not /var/jail.
}
Why this is vulnerable: chroot() only changes what / resolves to for future absolute-path lookups - it does not touch the process's current working directory. If that directory was outside /var/jail before the call (which it almost always is), every subsequent relative-path file operation still resolves against the real filesystem rather than the jail.
Remaining Privileged After Jail Setup
// VULNERABLE - correct chdir(), but still running as root afterward
void setup_jail_partial(void) {
if (chroot("/var/jail") != 0 || chdir("/") != 0) {
perror("chroot/chdir");
exit(1);
}
// BUG: still root. A root process inside the jail can call
// chroot() again on a subdirectory it creates, then chdir("..")
// repeatedly to climb back out to the real root.
run_service();
}
Why this is vulnerable: chroot() needs a privilege the caller happens to hold - on Linux, CAP_SYS_CHROOT, which a root process has by default - and entering the jail does not give it up. A still-privileged process inside a correctly-entered jail can call chroot() a second time on a directory it creates itself, which leaves its working directory (the old jail root) outside the new root, and then walk back up past it with repeated chdir("..") calls, escaping the original jail entirely. This is the escape sequence chroot(2)'s own manual page gives: mkdir foo; chroot foo; cd ...
A Directory Descriptor Held Across the Call
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
void run_service(void);
// VULNERABLE - correct chroot/chdir, but a descriptor outlives the jail
void setup_jail_leaky(void) {
int outside = open("/var/log", O_RDONLY | O_DIRECTORY); // opened before the jail
if (chroot("/var/jail") != 0 || chdir("/") != 0) {
perror("chroot/chdir");
exit(1);
}
// BUG: `outside` still refers to /var/log on the real filesystem.
// fchdir(outside) puts the working directory back outside the jail, and
// chdir("..") from there climbs to the real root - no privileges needed.
run_service();
}
Why this is vulnerable: chroot(2) states it plainly - "This call does not close open file descriptors, and such file descriptors may allow access to files outside the chroot tree." A directory descriptor is a working directory the process can return to with fchdir(), so holding one across the call reproduces exactly the weakness this CWE names, by a different handle. Unlike the re-chroot() escape above, this one does not need CAP_SYS_CHROOT - dropping privileges does not close it. Descriptors inherited from a parent process across fork() count, which is why a jailed service should not be started with arbitrary descriptors open.
Secure Patterns
chroot() Followed Immediately by chdir() and a Privilege Drop
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pwd.h>
#include <grp.h>
// SECURE - close descriptors, chroot, chdir, then drop privileges
void setup_jail_safe(void) {
// chroot() does not close open file descriptors, and a directory handle
// from before the call is a route back out via fchdir(). Close everything
// above stderr first. closefrom() is glibc 2.34+ and the BSDs; on older
// systems use close_range(3, ~0U, 0) or a loop over /proc/self/fd.
closefrom(3);
if (chroot("/var/jail") != 0) {
perror("chroot");
exit(1);
}
// Must happen immediately, before any file access
if (chdir("/") != 0) {
perror("chdir");
exit(1);
}
struct passwd *pw = getpwnam("nobody");
if (pw == NULL) {
fprintf(stderr, "user 'nobody' not found\n");
exit(1);
}
// Drop supplementary groups, then group, then user - in that order,
// since dropping the user first would remove permission to do the rest
if (setgroups(0, NULL) != 0 ||
setgid(pw->pw_gid) != 0 ||
setuid(pw->pw_uid) != 0) {
perror("privilege drop failed");
exit(1);
}
// Confirm the drop actually took effect
if (getuid() == 0 || geteuid() == 0) {
fprintf(stderr, "still running as root after setuid\n");
exit(1);
}
}
Why this works: chdir("/") immediately after chroot() moves the working directory inside the jail before any file operation can use a relative path to reach outside it. Closing the inherited descriptors first removes the other handle on the outside filesystem; one that survived the jail would make chdir("/") beside the point. Dropping privileges afterward removes CAP_SYS_CHROOT along with the rest, closing the re-chroot() escape available to a still-privileged process. Checking the return value of every step, including the post-drop getuid() check, means a silent failure in any one of them is caught rather than assumed to have worked.
chroot("/var/jail") then chdir("/") is one of two correct orderings; chdir("/var/jail") then chroot(".") is the other, and some daemons prefer it because chrooting to "." cannot be redirected by a symlink swapped in between the two calls. What is never correct is ending up with a working directory outside the jail, whichever route got you there.
Framework-Specific Guidance
Container and Namespace Isolation
Where the deployment target supports it, a container runtime or Linux namespaces provide real filesystem, process, and network isolation instead of relying on chroot()/chdir() being sequenced correctly by hand:
docker run --rm \
--read-only \
--tmpfs /tmp \
--user 1000:1000 \
--cap-drop ALL \
--security-opt no-new-privileges \
--network none \
myapp
Unlike chroot(), a container's filesystem namespace isn't escapable by re-invoking a single syscall from inside it, and the other flags shown here remove the network, capability, and privilege-escalation avenues that a bare chroot jail leaves open.
Testing
- Attempt
open("../../../etc/shadow", O_RDONLY)(or an equivalent relative-path escape) immediately after jail setup completes, and confirm it returns-1witherrno == ENOENTrather than a descriptor - inside a correctly entered jail,..at the root resolves to the root itself, so the path lands on/etc/shadowwithin the jail and finds nothing. - Attempt to call
chroot()a second time from inside the jailed process and confirm it returns-1witherrno == EPERM. Returning0means privileges were not actually dropped. - Confirm
getuid()/geteuid()return a non-zero, non-root value after setup. - Confirm both
chroot()andchdir()return values are checked - a silent failure of either leaves the jail only partially set up. - Walk
/proc/self/fdafter setup and confirm every entry resolves to a path inside the jail. A descriptor pointing outside is anfchdir()away from undoing the whole sequence, and no privilege check stands in its way.
Common Pitfalls
- Checking
chroot()'s return value but notchdir()'s: ifchdir("/")fails silently (the jail directory doesn't exist, or permissions are wrong), the process keeps running with its working directory outside the intended jail, with no indication anything went wrong. - Dropping privileges before calling
chroot()instead of after:chroot()requiresCAP_SYS_CHROOT, which the privilege drop removes; dropping privileges first means the call to create the jail fails, and the process silently continues running unjailed if that failure isn't checked. - Assuming a container's non-root default user is enough isolation on its own: running as a non-root user inside a container is a privilege drop, not a filesystem jail - it doesn't prevent path traversal within whatever the container's own filesystem exposes. It's a complementary control to the namespace isolation the container runtime itself provides, not a replacement for it.