openat2(2): path resolution you can actually trust
Published by RodHat

chroot(2) has been broken since before I had a root shell. Not broken in the sense that it doesn’t work. It works exactly as documented. The documentation is just honest that it does nothing useful unless you already have a real sandbox around it, in which case you didn’t need chroot.
The specific failure: chroot() requires CAP_SYS_CHROOT. It doesn’t close file descriptors. A process inside a chroot with an open fd to a directory outside it can fchdir() to that fd and walk right out. Hardlinks let you reach files whose paths don’t resolve inside the jail. And once a process escapes, there’s no kernel mechanism that notices.
The symlink problem is separate and has been quietly destroying archive extractors since 1990. Extract a tar file that contains a symlink evil -> ../.. followed by evil/etc/passwd -> <attacker payload> and you’ve written outside the extract directory. This is called zip slip. It still works in 2026 because open() does not give you a way to say “refuse to resolve this path outside the directory I gave you.”
Linux 5.6 (March 2020) added openat2(2). It does not fix chroot. It fixes path resolution.
The interface
#include <sys/syscall.h>
#include <linux/openat2.h>
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.mode = 0,
.resolve = RESOLVE_BENEATH,
};
long fd = syscall(SYS_openat2, dirfd, path, &how, sizeof(how));
openat2 takes the same dirfd and pathname as openat(2), then a struct open_how pointer and a size_t for the struct size. The struct has three fields: flags (same O_ bits as open), mode (same as open, only used when O_CREAT or O_TMPFILE is set), and resolve (the new thing).
glibc 2.36 added a openat2(3) wrapper. Before that, syscall() directly. The kernel header <linux/openat2.h> has the struct definition and the RESOLVE_* constants; you need it from a 5.6+ kernel-headers package.
One notable behavior: openat2 rejects the call with E2BIG if any field in the open_how struct that the running kernel does not recognize is non-zero. This is intentional. open(2) silently ignored unknown flags for decades, which is why we had security bugs from callers accidentally setting reserved bits. With openat2, you probe ABI version by calling it with sizeof(struct open_how) and checking for E2BIG or ENOSYS.
RESOLVE_BENEATH
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH,
};
long fd = syscall(SYS_openat2, dirfd, "../../etc/passwd", &how, sizeof(how));
/* returns -1, errno == EXDEV */
With RESOLVE_BENEATH, the kernel refuses to resolve any path that would escape the directory tree rooted at dirfd. Symlinks that point outside, .. past the root, absolute paths: all return EXDEV. The process’s actual root and CWD are unchanged. Only the path resolution relative to dirfd is constrained.
This is what an archive extractor needs. Before extracting each member:
int extract_fd = open(dest_dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
/* ... */
struct open_how how = {
.flags = O_WRONLY | O_CREAT | O_CLOEXEC,
.mode = 0644,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
};
long out = syscall(SYS_openat2, extract_fd, member->path, &how, sizeof(how));
if (out < 0 && (errno == EXDEV || errno == ELOOP)) {
/* path escapes or contains symlinks: reject the member */
log_and_skip(member->path);
continue;
}
RESOLVE_NO_SYMLINKS on top refuses to follow any symlink anywhere in the path. Combined with RESOLVE_BENEATH, you get: no traversal out of the tree, and no symlink tricks even within it. The zip slip attack becomes a syscall error instead of a file overwrite.
No root. No chroot. No subprocess. One flag.
RESOLVE_IN_ROOT
RESOLVE_IN_ROOT is the chroot semantics mode. Absolute paths, and .. past the dirfd, are clamped to the dirfd instead of returning EXDEV. A path of /etc/passwd resolves to <dirfd>/etc/passwd. A path of ../../../../etc/passwd also resolves to <dirfd>/etc/passwd.
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_IN_ROOT,
};
/* absolute paths are relative to dirfd, not process root */
long fd = syscall(SYS_openat2, jail_dir_fd, "/etc/passwd", &how, sizeof(how));
/* opens <jail_dir>/etc/passwd, not /etc/passwd */
The difference from RESOLVE_BENEATH: RESOLVE_BENEATH returns an error on escape. RESOLVE_IN_ROOT silently clamps. Which one you want depends on whether you trust the path to be well-formed (use RESOLVE_IN_ROOT when serving user-supplied absolute paths that should be jail-relative; use RESOLVE_BENEATH when you expect clean relative paths and want to audit anything that tries to escape).
One caveat: magic symlinks (/proc/self/fd/..., /proc/self/exe) can still point outside the RESOLVE_IN_ROOT boundary unless you also pass RESOLVE_NO_MAGICLINKS. They’re a separate category from regular symlinks in the kernel’s resolution code.
The other flags
RESOLVE_NO_SYMLINKS refuses all symlinks, magic or otherwise. Returns ELOOP.
RESOLVE_NO_XDEV refuses to cross mount points. Useful when dirfd is a local filesystem and you don’t want path resolution to walk onto an NFS or bind-mount. Returns EXDEV.
RESOLVE_NO_MAGICLINKS refuses procfs-style magic symlinks (/proc/*/fd/N, /proc/*/exe, etc.) without refusing regular symlinks. Returns ELOOP.
RESOLVE_CACHED is the one that surprises people: it fails if resolving the path would require a dcache miss, i.e., if the kernel would have to hit disk to look up a path component. Returns EAGAIN. This is useful in latency-sensitive hot paths where you’d rather retry later than block on a directory lookup. It’s not a security flag; it’s a scheduling hint.
Combining flags
The flags compose. RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS is the paranoid archive extractor. RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS is a more permissive container-like sandbox that allows regular symlinks but blocks /proc escape tricks. RESOLVE_BENEATH | RESOLVE_NO_XDEV confines resolution to one filesystem subtree.
The errors you get back are predictable:
EXDEV: tried to escape the dirfd boundary, or crossed a mount withRESOLVE_NO_XDEVELOOP: symlink whenRESOLVE_NO_SYMLINKSorRESOLVE_NO_MAGICLINKSblocked itENOSYS: kernel older than 5.6E2BIG: youropen_howstruct is larger than the kernel knows about (newer headers, older kernel)
FreeBSD got there first, again
FreeBSD 12.0, December 2018: O_RESOLVE_BENEATH as a flag to the regular open(2) family. Linux 5.6 shipped RESOLVE_BENEATH in March 2020, sixteen months later.
The implementations differ. FreeBSD added a flag to the existing open(2) syscall. Linux added a new syscall with a versioned struct. The Linux approach handles future extension better (add fields to open_how, gate on E2BIG); the FreeBSD approach was simpler to deploy. Both prevent the same class of traversal bugs.
For the RESOLVE_IN_ROOT equivalent on FreeBSD, you’re in Capsicum territory: capability mode plus cap_rights_limit() on directory fds, which is a broader confinement model than a single open flag. It’s more powerful and considerably more work to set up. openat2 with RESOLVE_IN_ROOT is narrower but trivially deployable.
The minimum viable hardening
If you write anything that opens files whose paths come from untrusted input (user uploads, archive members, config that references external paths), the pattern is:
int base = open(trusted_root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (base < 0) { err(1, "open base dir"); }
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH,
};
long fd = syscall(SYS_openat2, base, untrusted_path, &how, sizeof(how));
if (fd < 0) {
if (errno == ENOSYS) {
/* kernel < 5.6: fall back to manual validation or reject */
} else if (errno == EXDEV || errno == ELOOP) {
/* path escapes: reject */
} else {
/* ENOENT, EACCES, etc.: handle normally */
}
}
The fallback on ENOSYS matters. Anything shipping today should handle a 5.x kernel that predates 5.6. Either validate paths manually (check for .. components and symlinks, which is a correctness minefield, or just reject non-simple-relative paths), or document that the feature requires 5.6+.
On systems that have it, RESOLVE_BENEATH costs nothing. The kernel does the check during the same path walk it would do anyway. The security is free.
The seccomp-bpf post covers syscall filtering, which is complementary: openat2 constrains where you open, seccomp constrains which syscalls the process can call at all. The fanotify post covers the access-control side of fanotify, which can enforce similar policies at the VFS layer but requires a privileged daemon rather than being self-contained in the sandboxed process.