Linux openat2: Constraining Path Resolution Against Symlink and Mount Escapes
How openat2 applies explicit path-resolution policy, how its flags differ, and why safe file use still requires directory FDs, validation, and testing.
Checking a pathname and opening it later is a classic race. An attacker who controls part of the tree can replace a directory with a symlink, move a mount, or use a magic link between those operations. Comparing normalized strings does not constrain what the kernel’s pathname walker will actually resolve.
Linux added openat2() in 5.6 so a caller can combine ordinary open flags with explicit resolution policy. Its struct open_how can forbid symlinks, magic links, mount crossings, or escape from an anchored directory. The result is still a file descriptor, which gives the application a stable handle after lookup instead of requiring another path-based access.
Start from a trusted directory descriptor
The dirfd argument gives a relative pathname a starting directory. Open that anchor before accepting untrusted input, normally with O_PATH | O_DIRECTORY | O_CLOEXEC, and keep the descriptor private. Renaming the directory later does not turn the descriptor into a different directory.
int root = open("/srv/uploads", O_PATH | O_DIRECTORY | O_CLOEXEC);
if (root == -1)
fail("open upload root");
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
int fd = syscall(SYS_openat2, root, user_path, &how, sizeof(how));
Use a relative user_path with RESOLVE_BENEATH. An absolute pathname is not made safe merely because a directory descriptor was supplied. Also protect the anchor itself: if a less-trusted component can replace the descriptor in your process or choose which directory gets opened, resolution flags cannot recover the intended policy.
BENEATH and IN_ROOT express different policies
RESOLVE_BENEATH rejects resolution that escapes above the supplied directory. Absolute paths and absolute symbolic links are rejected. It fits an API whose accepted names are explicitly relative to a directory.
RESOLVE_IN_ROOT instead treats dirfd as a temporary root for this lookup. A leading slash and an absolute symbolic link are interpreted relative to that root, and .. at the top remains there. It resembles a per-operation chroot for path resolution without changing process-global state.
Do not set both by reflex. Decide whether an absolute-looking input is invalid or should be scoped inside a synthetic root. That difference affects compatibility and security review, and it deserves tests for leading slashes, repeated .., and absolute symlink targets.
Symlinks and magic links are not identical
RESOLVE_NO_SYMLINKS rejects any symbolic link in every component of the path. It is the strongest simple rule, but it also rejects benign symlinks administrators may rely on. If the final component is a symlink and the open flags use O_PATH | O_NOFOLLOW, the call can return a descriptor for the link itself rather than following it, as documented by openat2(2).
RESOLVE_NO_MAGICLINKS targets procfs-style magic links such as some entries under /proc/<pid>/fd. These objects can refer outside an apparent tree and have semantics beyond an ordinary stored symlink target. The manual notes that RESOLVE_NO_SYMLINKS currently implies the magic-link restriction, but applications should request the policy they rely on rather than depending on that implication forever.
Rejecting every symlink is not always required. A package viewer may safely allow links while preventing escape beneath a trusted tree. A privileged extractor may choose the stricter policy. State the allowed object model first, then select flags.
NO_XDEV blocks every mount crossing
RESOLVE_NO_XDEV prevents the walk from crossing a mount point, including bind mounts. That stops an attacker-controlled subtree from redirecting lookup into another filesystem through a mount, but it can also reject intentional layouts where /srv/uploads contains mounted volumes.
This is a namespace rule, not just a physical-device rule. Bind mounting another part of the same filesystem still creates a mount boundary. Test on the actual mount namespace used by the service, especially inside containers where runtime-created bind mounts are common.
A mount can still be changed before the call begins. The guarantee is about the one kernel resolution operation. If the application later reopens a child by a saved string, it starts a new race. Continue operating through the returned descriptor and descriptor-relative APIs.
CACHED creates an explicit fast-path contract
RESOLVE_CACHED asks the kernel to complete resolution without I/O or revalidation that would leave the cache-only path. If that is not possible, the call returns EAGAIN. A latency-sensitive worker can use this as a fast path and delegate a normal blocking lookup to another thread or queue.
EAGAIN is not “file missing.” It can also be returned in security-sensitive resolution cases where the kernel cannot prove the requested constraint without retry. Error handling must preserve that distinction. Do not convert every failure into ENOENT and do not spin immediately on EAGAIN in the same overloaded event loop.
Cache-only lookup is an optimization layered on the same access checks. It does not pin content in memory, validate file bytes, or promise that a later read cannot block.
The extensible structure requires defensive initialization
openat2() receives the size of struct open_how. Zero-fill the entire structure, set only known fields, and pass the size compiled into the program. The API can reject unknown nonzero bits or unsupported structure extensions rather than silently ignoring security policy.
The manual documents version negotiation behavior for structure sizes. A portable runtime should distinguish ENOSYS, meaning the kernel lacks the syscall, from EINVAL, E2BIG, policy failures such as EXDEV or ELOOP, and ordinary open errors. Current glibc documentation may not provide a wrapper on all supported combinations, so many programs call it through syscall() and provide their own guarded definitions.
A fallback to openat() must not be silent when the resolution policy is security-critical. Either implement and prove an equivalent descriptor-walking strategy, reject the operation on older kernels, or run the feature only in a reduced-trust mode that is clearly disclosed.
Opening safely is only the first boundary
After obtaining the descriptor, use fstat() to verify file type, ownership, mode, device, and size constraints. Open with O_NOFOLLOW, O_DIRECTORY, O_NONBLOCK, or write restrictions appropriate to the expected object. A FIFO, device node, huge sparse file, or procfs object can be dangerous even when it is located beneath the correct directory.
Avoid turning /proc/self/fd/<n> back into a string path for a library that then resolves it independently. Pass the descriptor directly, or use an API designed to consume descriptors. If a tool requires a pathname, understand that the security boundary has changed.
Test with concurrent attackers: swap symlinks, rename parent directories, create bind mounts where the service permits it, and race deletion and recreation. openat2() gives the kernel enough information to enforce one lookup policy atomically. The application still has to define the right policy and keep all later work on the descriptor it earned.
Related:
- Linux’s File-Descriptor Mount API: fsopen, fsmount, and move_mount
- Linux pidfds: Race-Free Process Handles Beyond Numeric PIDs
Sources: