Hệ điều hành · 03/10/2026

Practical Linux openat2: Preventing Path Traversal and Symlink Races

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.

Linux openat2 thực chiến: Chặn path traversal và symlink race

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>/fd or /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-based chmod() 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_hardlinks still 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

errnoTypical meaningAction
EXDEVBENEATH/IN_ROOT escape or mount crossing under NO_XDEVReject input; log the violation class without leaking paths.
ELOOPA forbidden symlink or magic link was encounteredReject or use a separately reviewed policy.
EAGAINKernel could not prove safety during a rename race, or CACHED missedRetry with a limit while preserving security flags.
ENOSYSThe kernel lacks openat2Fail closed or use an assessed fallback.
EINVALInvalid flag, mode, size, or unsupported flagFeature-detect; do not silently remove constraints.
E2BIGKernel does not support a structure extensionFollow the extensibility contract instead of truncating blindly.
ENOENTA component or target does not existHandle 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:

  1. Require Linux 5.6 or newer for components handling untrusted paths.
  2. Run the function in a low-privilege helper, container, or private mount namespace.
  3. Use an audited library with explicit semantics.
  4. If implementing a component walk, document weaker guarantees and fuzz and race-test it.
  5. 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/fd in 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_how and 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.

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

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