$RodHat_
Console Tips

openat2: enforce path resolution constraints at the syscall level

Published by

openat2: enforce path resolution constraints at the syscall level
Photo: AI-generated — no human photographer / RodHat AI Cover

openat() fixed the open()/access() race condition in 2.6.16 and then stopped trying. It gave you a directory file descriptor as an anchor for relative paths, which was exactly the right model. What it did not give you was any way to stop the kernel from crawling ../../ back out of that directory, dereferencing a symlink into /etc/passwd, or crossing a bind mount you didn’t expect to be there. The anchor was advisory at best.

openat2() shipped in Linux 5.6 (2020) and finishes the job. It adds a third argument, struct open_how, with a resolve field that takes a bitmask of constraints. The kernel enforces these during pathname lookup, before touching the file. No root required. No realpath() in userspace, no chroot dance, no race between your validation logic and the open itself.

The struct

#define _GNU_SOURCE
#include <linux/openat2.h>  /* struct open_how, RESOLVE_* flags */
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>

/* glibc 2.36+ has openat2() in <fcntl.h>. Older glibc: call it directly. */
#ifndef SYS_openat2
#define SYS_openat2 437     /* x86-64; check asm/unistd.h for your arch */
#endif

static inline int my_openat2(int dfd, const char *path,
                              struct open_how *how, size_t size)
{
    return (int)syscall(SYS_openat2, dfd, path, how, size);
}

The struct has three fields:

struct open_how {
    __u64 flags;    /* same O_RDONLY, O_WRONLY, O_CREAT, etc. as openat() */
    __u64 mode;     /* same as openat() mode — only meaningful with O_CREAT/O_TMPFILE */
    __u64 resolve;  /* the new part: RESOLVE_* bitmask */
};

Always pass sizeof(struct open_how) as the fourth argument. The kernel uses the size to determine which fields you populated, same ABI extension pattern as clone3() and statx().

RESOLVE_BENEATH: the main event

This is the flag you want for any code that opens user-supplied paths relative to a directory you control.

int open_beneath(int dirfd, const char *untrusted_path, int oflags)
{
    struct open_how how = {
        .flags   = oflags,
        .resolve = RESOLVE_BENEATH,
    };

    int fd = my_openat2(dirfd, untrusted_path, &how, sizeof(how));
    if (fd < 0 && errno == EXDEV) {
        /* Path tried to escape the directory. Reject it. */
        return -EXDEV;
    }
    return fd;
}

With RESOLVE_BENEATH, if the resolved path would escape the directory tree rooted at dirfd, the kernel returns EXDEV. That includes:

  • ../ sequences that would cross above the anchor directory
  • Absolute paths (/etc/passwd) are also rejected; they would trivially escape
  • Symlinks that point outside the anchor subtree

The check happens at lookup time inside the kernel. It is not a TOCTOU-vulnerable userspace comparison against realpath(). By the time openat2() returns a file descriptor, the kernel has already confirmed the path stayed in bounds.

One thing to know: RESOLVE_BENEATH does not block symlinks that stay within the anchored subtree. If /srv/web/images/thumb.jpg is a symlink to /srv/web/images/original.jpg, that resolves fine. Only escaping symlinks get rejected. If you want to refuse all symlinks, combine it with RESOLVE_NO_SYMLINKS.

RESOLVE_NO_SYMLINKS is the blunt instrument: the kernel refuses to follow any symlink anywhere in the path, including the final component. Useful when you want the caller to work with the actual inode, not a pointer to one.

RESOLVE_NO_MAGICLINKS is more surgical. It blocks “magic links”: the synthetic symlinks in /proc/PID/fd/, /proc/PID/exe, and /proc/self/root that don’t point to paths in the normal filesystem but instead resolve against kernel object references. These are the symlinks that make open("/proc/self/fd/3") bypass filesystem namespace boundaries. An attacker who can create a magic link in a directory you control can escape containment through it. RESOLVE_NO_MAGICLINKS cuts that off without prohibiting normal symlinks.

struct open_how how = {
    .flags   = O_RDONLY,
    .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};

This is a reasonable default for a process serving static files from a document root. The combination blocks escapes via both ../../ and magic link tricks.

RESOLVE_IN_ROOT: chroot without root

RESOLVE_IN_ROOT treats absolute paths in path as relative to dirfd rather than the filesystem root. It is similar to what chroot gives you, minus the privilege requirement.

int dirfd = open("/srv/web", O_PATH | O_DIRECTORY | O_CLOEXEC);

struct open_how how = {
    .flags   = O_RDONLY,
    .resolve = RESOLVE_IN_ROOT,
};

/* This opens /srv/web/etc/passwd, not /etc/passwd. */
int fd = my_openat2(dirfd, "/etc/passwd", &how, sizeof(how));

The kernel also handles .. at the virtual root: trying to go above dirfd just stays at dirfd, same as chroot does at the real root. This makes it safe to pass user-supplied paths that may start with / without needing to strip leading slashes first.

RESOLVE_IN_ROOT and RESOLVE_BENEATH are mutually exclusive. If you want both behaviors, RESOLVE_IN_ROOT is the stronger guarantee.

RESOLVE_NO_XDEV: stay on one filesystem

RESOLVE_NO_XDEV blocks path resolution from crossing filesystem boundaries. If anything in the path is a bind mount or a mounted filesystem, openat2() returns EXDEV. Useful for processes that should only see one volume and should not be affected by whatever the admin mounts on top of its directories later.

RESOLVE_CACHED: latency-sensitive opens

Added in Linux 5.12. With RESOLVE_CACHED, the kernel only resolves the path from the dentry cache. If any component of the path requires a blocking lookup (disk read, network, or anything that might sleep), the call fails with EAGAIN immediately. Useful in hot paths where a cache miss is already a signal that the path is anomalous.

Checking support and the glibc wrapper

# Kernel version check
uname -r   # need 5.6+; 5.12+ for RESOLVE_CACHED

# Quick smoke test from the shell (Python, no compile step)
python3 - <<'EOF'
import ctypes, errno, os

SYS_openat2 = 437  # x86-64
AT_FDCWD = -100

class OpenHow(ctypes.Structure):
    _fields_ = [('flags', ctypes.c_uint64),
                ('mode',  ctypes.c_uint64),
                ('resolve', ctypes.c_uint64)]

RESOLVE_BENEATH = 0x08
O_RDONLY = 0
O_PATH   = 0x200000

libc = ctypes.CDLL(None, use_errno=True)
how = OpenHow(flags=O_PATH, mode=0, resolve=RESOLVE_BENEATH)

# Try to open /etc/passwd beneath /tmp — should get EXDEV
fd = libc.syscall(SYS_openat2,
    ctypes.c_int(AT_FDCWD),
    b'/tmp',
    ctypes.byref(how),
    ctypes.c_size_t(ctypes.sizeof(how)))

dirfd = libc.open(b'/tmp', O_PATH | O_RDONLY)
how2 = OpenHow(flags=O_PATH, mode=0, resolve=RESOLVE_BENEATH)
fd2 = libc.syscall(SYS_openat2,
    ctypes.c_int(dirfd),
    b'../etc/passwd',
    ctypes.byref(how2),
    ctypes.c_size_t(ctypes.sizeof(how2)))

err = ctypes.get_errno()
print(f'escape attempt result: fd={fd2}, errno={err} (EXDEV={errno.EXDEV})')
print('RESOLVE_BENEATH working:', err == errno.EXDEV)
EOF

Glibc 2.36 (shipped in Debian 12, Ubuntu 23.04, Fedora 37 and later) provides openat2() directly in <fcntl.h>. On older glibc, the syscall() wrapper above works fine. The syscall number is 437 on x86-64; check asm/unistd.h for arm64 (437 as well), riscv64 (437), or other architectures.

The full pattern for a static file server

#define _GNU_SOURCE
#include <linux/openat2.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>

#define SYS_openat2 437

static int g_docroot_fd = -1;

int init_docroot(const char *path)
{
    g_docroot_fd = open(path, O_PATH | O_DIRECTORY | O_CLOEXEC);
    return g_docroot_fd;
}

int open_request(const char *user_path)
{
    struct open_how how = {
        .flags   = O_RDONLY | O_CLOEXEC | O_NOFOLLOW,
        .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
    };

    int fd = (int)syscall(SYS_openat2,
                          g_docroot_fd, user_path,
                          &how, sizeof(how));

    if (fd < 0) {
        if (errno == EXDEV) {
            /* Path escape attempt — log and 403. */
            fprintf(stderr, "path escape blocked: %s\n", user_path);
            errno = EACCES;
        }
        return -1;
    }
    return fd;
}

The O_NOFOLLOW on the flags is belt-and-suspenders for the final path component, on top of what RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS already handles. Costs nothing.

What this does not do

openat2() resolves paths. It does not restrict what the process does after it has the file descriptor. If you want to restrict which syscalls the process can call on that fd, add seccomp-BPF. If you want to confine the process’s filesystem access globally rather than per-open, landlock applies process-wide rules with the same no-root requirement.

The two mechanisms compose cleanly: use openat2() for fine-grained control over individual opens in security-critical code paths, use landlock to set the outer boundary for the whole process, and add seccomp-BPF if you want to lock down which syscalls reach the kernel at all.


The landlock post covers process-wide filesystem confinement without root, which is the broader-scope version of what RESOLVE_BENEATH does per-open. The seccomp-BPF post is the syscall-level layer that stacks with both. The pidfd post covers the same era of kernel hardening work: modern syscalls designed to close race conditions that the original POSIX interfaces left open.