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:
.. escaping, no absolute paths, no symlink games./proc (like /proc/self/fd/N) that let you jump anywhere the target file lives./. Absolute paths and .. at the root are clamped, not rejected. This is the container-runtime flag.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.
lstat()-then-open() code could ever solve correctly.
