The Linux openat2() Syscall and RESOLVE_BENEATH: How the Kernel Solved 40 Years of Path Traversal Bugs

2026-09-14

For decades, safely opening a file inside a "jail" directory required user-space to walk the path component-by-component, calling lstat() on each piece, checking symlinks, then racing the filesystem to open() before something changed underneath you. Every web server, container runtime, and archive extractor rolled its own broken version of this dance. openat2(), added in Linux 5.6 (2020), pushes the check into the kernel where the lookup happens atomically.

The syscall takes a struct open_how with a resolve flags field. The four flags that matter:

Concrete example. A file-upload service extracts a tarball into /var/uploads/user123/. A malicious tarball contains an entry named ../../../etc/cron.d/pwn. With openat(), you must manually reject .. components — and if any parent directory contains a symlink, your check is still wrong. With openat2(dirfd, "../../../etc/cron.d/pwn", &how) where how.resolve = RESOLVE_BENEATH, the kernel returns -EXDEV the moment the lookup steps above dirfd. One syscall, race-free.

Why user-space couldn't do this correctly. The classic TOCTOU: between your lstat() and your open(), another process replaces a directory component with a symlink to /etc. Even O_NOFOLLOW only checks the final component. The only race-free solution is opening each directory with O_PATH | O_NOFOLLOW and walking the chain with openat() — dozens of syscalls per path. openat2 does the same walk in one trip through path_openat() with the resolve flags checked at every walk_component().

Rule of thumb. If your service takes a path from an untrusted source and wants to open it inside a fixed root: use openat2() with RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS. If you additionally want to forbid all symlink following (stricter, safer for archive extraction), add RESOLVE_NO_SYMLINKS. The syscall costs one ENOSYS check to fall back on pre-5.6 kernels — cheaper than the bugs you're avoiding.

Note that RESOLVE_BENEATH also blocks bind mounts crossing out of the subtree and mount-point traversals (via RESOLVE_NO_XDEV combined), which the old walk-and-check pattern never handled at all.

Key Takeaway: openat2() moves path-safety checks into the kernel's atomic lookup, killing the entire class of TOCTOU path-traversal bugs that no amount of user-space lstat()-then-open() code could ever solve correctly.

All newsletters