An application receives invoices/2026/report.pdf from a user and intends to open only files under /srv/uploads. It rejects ../, calls realpath(), and verifies the prefix—but an attacker swaps a symlink between validation and open(), causing a file outside the sandbox to be opened.
openat2() moves path-resolution constraints into one kernel system call, eliminating races that string sanitization and check-then-open cannot address. This guide covers RESOLVE_BENEATH, RESOLVE_IN_ROOT, RESOLVE_NO_SYMLINKS, RESOLVE_NO_MAGICLINKS, RESOLVE_NO_XDEV, correct error handling, and safe fallback design. It follows openat2(2), path_resolution(7), and the Linux kernel's pathname lookup documentation.
1. Path traversal is more than the string ../
A lexical filter may block ../, but path resolution is also affected by:
- symbolic links in any component;
- absolute symlinks pointing at the filesystem root;
- magic links under
/proc/<pid>/fdor/proc/<pid>/exe; - bind mounts or mount points inside the permitted tree;
- concurrent directory renames;
- absolute paths that bypass string-join logic;
- changes between validation and opening.
This pattern is unsafe:
char *resolved = realpath(user_path, NULL);
if (starts_with(resolved, "/srv/uploads/")) {
fd = open(resolved, O_RDONLY);
}
An attacker can change the hierarchy between realpath() and open(). Prefix checks also fail around boundaries such as /srv/uploads-evil, encoding, and nonexistent targets created later.
Do not validate a pathname and then reopen it by name. Ask the kernel to resolve and enforce constraints in one operation, then continue with the returned file descriptor.
2. How openat2 extends openat
openat2() was introduced in Linux 5.6. Like openat(), it resolves a relative path from a directory file descriptor. Its key addition is the resolve field in open_how, which constrains the entire lookup.
#include <fcntl.h>
#include <linux/openat2.h>
#include <sys/syscall.h>
#include <unistd.h>
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.mode = 0,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
int fd = syscall(
SYS_openat2,
rootfd,
user_path,
&how,
sizeof(how)
);
Current man-pages note that glibc has no wrapper, so applications call syscall(). Zero-initialize the complete open_how structure for compatibility with future headers. The size must describe the structure actually passed.
3. Anchor lookup with a directory descriptor
Open the trusted root at startup:
int rootfd = open(
"/srv/uploads",
O_PATH | O_DIRECTORY | O_CLOEXEC
);
if (rootfd == -1) {
/* fail startup */
}
O_PATH creates a directory handle without opening its contents for reading or writing. If the directory is renamed, the descriptor still refers to that object. The configured root must be trusted; inspect it with fstat() and validate ownership, mode, and mount policy where required.
User paths should then be relative to rootfd, unless the application deliberately chooses RESOLVE_IN_ROOT semantics.
4. RESOLVE_BENEATH: never escape the root
RESOLVE_BENEATH requires every resolved component to remain a descendant of dirfd. It rejects:
- absolute paths;
..or symlinks escaping the root;- absolute symbolic links;
- other escapes detected during lookup.
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_MAGICLINKS
Although BENEATH currently blocks magic links, the man page recommends specifying RESOLVE_NO_MAGICLINKS explicitly because implicit behavior may change.
BENEATH fits an API that accepts relative paths such as tenant-a/report.pdf and treats every absolute path as invalid input.
5. RESOLVE_IN_ROOT: a temporary root for one open
RESOLVE_IN_ROOT treats dirfd as the filesystem root for one lookup:
- absolute paths are interpreted relative to
dirfd; - absolute symlinks resolve inside the temporary root;
..at the root behaves like/..and stays at the root.
.resolve = RESOLVE_IN_ROOT |
RESOLVE_NO_MAGICLINKS
This resembles a per-open chroot without changing the process root. It is useful for filesystem images, container root filesystems, or archive paths that may begin with /.
Do not combine BENEATH and IN_ROOT without a defined contract. Choose either “relative descendants only” or “interpret the path inside a private root.”
6. O_NOFOLLOW differs from RESOLVE_NO_SYMLINKS
O_NOFOLLOW affects only the final path component. It does not block:
safe-dir/link-to-outside/secret.txt
when the middle component is a symlink. RESOLVE_NO_SYMLINKS rejects symlinks in every component and implies NO_MAGICLINKS:
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_SYMLINKS
This is strong for upload spools, extraction roots, or tenant-controlled directories. Symlinks are common in system filesystems, however, and enabling this indiscriminately can break applications after a distribution update. Scope it to a specific security purpose and make it configurable where valid symlinks are required.
7. Magic links are not ordinary symlinks
Procfs magic links can reference kernel objects or file descriptors and do not follow every ordinary symlink semantic. Examples include:
/proc/self/fd/7
/proc/1234/exe
In containers or privileged processes, following one may expose an object outside the expected view or acquire a controlling terminal. Explicitly use:
RESOLVE_NO_MAGICLINKS
even when relying on BENEATH or IN_ROOT. NO_SYMLINKS already includes this restriction.
8. RESOLVE_NO_XDEV blocks mount traversal
RESOLVE_NO_XDEV rejects every mount-point traversal, including bind mounts:
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_MAGICLINKS |
RESOLVE_NO_XDEV
It is useful when a tenant may affect the mount namespace or the allowed directory must never contain another mount. But legitimate bind mounts are common in containers and deployments, so enabling NO_XDEV globally may break normal layouts.
The threat model must answer whether mounts inside the root are trusted. If only administrators control the namespace, BENEATH may be sufficient for path confinement. If an attacker can create or move mounts, combine namespace and capability restrictions with an appropriate NO_XDEV policy.
9. A secure read-only wrapper
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <stdint.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
static int secure_open_beneath(int rootfd, const char *path)
{
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.mode = 0,
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_SYMLINKS |
RESOLVE_NO_XDEV,
};
int fd = syscall(
SYS_openat2,
rootfd,
path,
&how,
sizeof(how)
);
if (fd == -1)
return -1;
struct stat st;
if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode)) {
int saved = errno ? errno : EINVAL;
close(fd);
errno = saved;
return -1;
}
return fd;
}
Path confinement does not prove the final object is a regular file. Use fstat() on the descriptor to reject directories, devices, FIFOs, or sockets. Continue reading or writing through the descriptor; never reopen the pathname.
10. Create a file safely
openat2() supports O_CREAT. Set the mode correctly and constrain the parent hierarchy:
struct open_how how = {
.flags = O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
.mode = 0600,
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_SYMLINKS |
RESOLVE_NO_XDEV,
};
O_EXCL prevents overwriting an existing target when the workflow needs unique names. Umask still affects the mode. For atomic publication, consider a temporary file in the same directory and filesystem followed by appropriate fsync and renameat2() operations.
Do not call access() before open(). Attempt the open under the real credentials and handle its result; check-then-open creates another race.
11. openat2 does not secure every pathname operation
openat2() returns a safely opened descriptor. It does not automatically make every other pathname operation race-free:
unlink(),rename(), and path-basedchmod()have their own contracts;- opening a parent safely and calling
unlinkat()on a basename narrows scope but still needs a concurrent-rename threat model; - archive extraction creates directories, symlinks, hard links, and metadata, not only files;
- a hard link can place a sensitive inode inside a tree when permissions allow, so DAC and
fs.protected_hardlinksstill matter.
Prefer descriptor-based APIs, strict directory ownership, a private mount namespace, and a low-privilege worker. openat2 is a strong primitive, not a complete sandbox.
12. Handle errno by cause
| errno | Typical meaning | Action |
|---|---|---|
EXDEV | BENEATH/IN_ROOT escape or mount crossing under NO_XDEV | Reject input; log the violation class without leaking paths. |
ELOOP | A forbidden symlink or magic link was encountered | Reject or use a separately reviewed policy. |
EAGAIN | Kernel could not prove safety during a rename race, or CACHED missed | Retry with a limit while preserving security flags. |
ENOSYS | The kernel lacks openat2 | Fail closed or use an assessed fallback. |
EINVAL | Invalid flag, mode, size, or unsupported flag | Feature-detect; do not silently remove constraints. |
E2BIG | Kernel does not support a structure extension | Follow the extensibility contract instead of truncating blindly. |
ENOENT | A component or target does not exist | Handle as business state, not proof of traversal. |
For BENEATH or IN_ROOT EAGAIN caused by concurrent rename, retry the entire call a bounded number of times, then fail closed. Never retry with unconstrained open().
13. RESOLVE_CACHED is a performance optimization
RESOLVE_CACHED requires lookup to complete from the dentry cache. If I/O or revalidation is needed, it returns EAGAIN. An event loop can try a fast path and offload the slow path:
resolve = security_flags | RESOLVE_CACHED;
if (errno == EAGAIN) {
resolve = security_flags; /* drop CACHED, retain constraints */
/* retry in an appropriate worker */
}
A cache hit does not make a path trusted. Security flags must remain on both attempts.
14. Fallback on older kernels
No simple fallback reproduces all openat2 guarantees. Walking components with openat(), O_PATH, O_NOFOLLOW, and directory descriptors can reduce risk, but symlinks, mounts, renames, final components, and errors require careful design.
Prefer this order:
- Require Linux 5.6 or newer for components handling untrusted paths.
- Run the function in a low-privilege helper, container, or private mount namespace.
- Use an audited library with explicit semantics.
- If implementing a component walk, document weaker guarantees and fuzz and race-test it.
- Fail closed for privileged upload or extraction when the primitive is unavailable.
Never fall back to realpath() + prefix check + open() and claim equivalent protection.
15. Design the application API above openat2
Keep the application contract simple:
- accept a relative path decoded exactly once;
- limit length and reject NUL bytes in the language layer;
- never concatenate a root string with the user path;
- map each tenant to a separate rootfd instead of a name prefix;
- return generic client errors while logging errno and policy internally;
- do not cache a “validated” pathname; cache trusted root descriptors with clear lifecycles;
- retain the opened descriptor throughout processing.
If internal symlinks are valid, use BENEATH plus NO_MAGICLINKS and test absolute and relative symlinks. If symlinks are unnecessary, add NO_SYMLINKS for a contract that is easier to audit.
16. Adversarial testing
Test at least:
../secret, multiple..levels, and absolute paths;- final and intermediate symbolic links;
- absolute symlinks and relative symlinks escaping the root;
- magic links through
/proc/self/fdin a test namespace; - bind mounts and mount points with NO_XDEV on and off;
- a thread continuously swapping symlinks and renaming directories during concurrent opens;
- FIFO, socket, device, and directory targets instead of regular files;
- a file renamed after open—the descriptor must remain attached to the opened object;
- kernels without openat2 or particular flags;
- EAGAIN retry budgets, logs, and metrics.
Run race tests inside a mount or user namespace or an isolated VM. Never create mount and symlink storms in production.
17. Production checklist
- Open the trusted root with
O_PATH|O_DIRECTORY|O_CLOEXEC. - Choose BENEATH or IN_ROOT from the path contract.
- Specify NO_MAGICLINKS explicitly.
- Use NO_SYMLINKS and NO_XDEV when required by the threat model.
- Zero-initialize
open_howand pass the correct size. - After opening, validate type and metadata with
fstat(). - Operate through the descriptor and never reopen the pathname.
- Bound EAGAIN retries without dropping security flags.
- Define fail-closed or fallback behavior for ENOSYS, EINVAL, and E2BIG.
- Fuzz paths and race-test symlinks, renames, and mounts.
Conclusion
Securing an untrusted path is not a matter of removing ../ from a string. Linux resolves pathnames through mutable components, symlinks, magic links, and mount points. openat2() places boundaries directly inside atomic lookup and returns a stable file descriptor. By anchoring to a trusted rootfd, selecting deliberate resolve flags, validating the opened object, and failing closed on older kernels, an application can remove a class of path-traversal and TOCTOU bugs that “realpath then open” cannot reliably prevent.




No comments yet. Be the first to share your thoughts.