$RodHat_
Console Tips

openat2(2): path resolution you can actually trust

Published by · Updated

This article may contain Amazon affiliate links. I may earn a commission if you buy through them. Disclosure.

openat2(2): path resolution you can actually trust
Photo: AI-generated — no human photographer / RodHat AI Cover
On this page · 10 sections
  1. The interface
  2. RESOLVE_BENEATH
  3. RESOLVE_IN_ROOT
  4. The other flags
  5. Combining flags
  6. Checking support, and the glibc situation
  7. FreeBSD got there first, again
  8. The minimum viable hardening
  9. The full pattern for a static file server
  10. What this does not do

chroot(2) has been broken since before I had a root shell. Not broken in the sense that it doesn’t work. It works exactly as documented. The documentation is just honest that it does nothing useful unless you already have a real sandbox around it, in which case you didn’t need chroot.

The specific failure: chroot() requires CAP_SYS_CHROOT. It doesn’t close file descriptors. A process inside a chroot with an open fd to a directory outside it can fchdir() to that fd and walk right out. Hardlinks let you reach files whose paths don’t resolve inside the jail. And once a process escapes, there’s no kernel mechanism that notices.

The symlink problem is separate and has been quietly destroying archive extractors since 1990. Extract a tar file that contains a symlink evil -> ../.. followed by evil/etc/passwd -> <attacker payload> and you’ve written outside the extract directory. This is called zip slip. It still works in 2026 because open() does not give you a way to say “refuse to resolve this path outside the directory I gave you.”

Linux 5.6 (March 2020) added openat2(2). It does not fix chroot. It fixes path resolution.

The interface

#include <sys/syscall.h>
#include <linux/openat2.h>

struct open_how how = {
    .flags   = O_RDONLY | O_CLOEXEC,
    .mode    = 0,
    .resolve = RESOLVE_BENEATH,
};

long fd = syscall(SYS_openat2, dirfd, path, &how, sizeof(how));

openat2 takes the same dirfd and pathname as openat(2), then a struct open_how pointer and a size_t for the struct size. The struct, straight out of <linux/openat2.h>:

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

Three 64-bit fields, no padding, no cleverness. Always pass sizeof(struct open_how) as the fourth argument. The size is the version number: the kernel uses it to figure out which fields you know about, the same extensible-struct pattern clone3() uses. Zero-fill the struct (designated initializers do this for you) so that recompiling against newer headers with more fields doesn’t hand the kernel garbage.

One notable behavior: openat2 rejects the call with E2BIG if any field in the open_how struct that the running kernel does not recognize is non-zero. Unknown bits in flags or resolve get EINVAL, and so does a non-zero mode without O_CREAT or O_TMPFILE. This is intentional. open(2) silently ignored unknown flags for decades, which is why we had security bugs from callers accidentally setting reserved bits. With openat2, you probe ABI version by calling it with sizeof(struct open_how) and checking for E2BIG or ENOSYS.

RESOLVE_BENEATH

struct open_how how = {
    .flags   = O_RDONLY | O_CLOEXEC,
    .resolve = RESOLVE_BENEATH,
};
long fd = syscall(SYS_openat2, dirfd, "../../etc/passwd", &how, sizeof(how));
/* returns -1, errno == EXDEV */

With RESOLVE_BENEATH, the kernel refuses to resolve any path that would escape the directory tree rooted at dirfd. Symlinks that point outside, .. past the root, absolute paths: all return EXDEV. The process’s actual root and CWD are unchanged. Only the path resolution relative to dirfd is constrained.

The check happens inside the kernel during lookup. It is not a TOCTOU-shaped userspace comparison against realpath() that some other process can race between your check and your open(). By the time you have an fd, the kernel has already confirmed the walk stayed in bounds.

What RESOLVE_BENEATH does not do is refuse symlinks that stay inside the tree. If images/thumb.jpg points at images/original.jpg under the same dirfd, that resolves fine. Only escaping symlinks (including any absolute symlink, which escapes by definition) get rejected.

This is what an archive extractor needs. Before extracting each member:

int extract_fd = open(dest_dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
/* ... */
struct open_how how = {
    .flags   = O_WRONLY | O_CREAT | O_CLOEXEC,
    .mode    = 0644,
    .resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
};
long out = syscall(SYS_openat2, extract_fd, member->path, &how, sizeof(how));
if (out < 0 && (errno == EXDEV || errno == ELOOP)) {
    /* path escapes or contains symlinks: reject the member */
    log_and_skip(member->path);
    continue;
}

RESOLVE_NO_SYMLINKS on top refuses to follow any symlink anywhere in the path. Combined with RESOLVE_BENEATH, you get: no traversal out of the tree, and no symlink tricks even within it. The zip slip attack becomes a syscall error instead of a file overwrite.

No root. No chroot. No subprocess. One flag.

RESOLVE_IN_ROOT

RESOLVE_IN_ROOT is the chroot semantics mode. Absolute paths, and .. past the dirfd, are clamped to the dirfd instead of returning EXDEV. A path of /etc/passwd resolves to <dirfd>/etc/passwd. A path of ../../../../etc/passwd also resolves to <dirfd>/etc/passwd.

struct open_how how = {
    .flags   = O_RDONLY | O_CLOEXEC,
    .resolve = RESOLVE_IN_ROOT,
};
/* absolute paths are relative to dirfd, not process root */
long fd = syscall(SYS_openat2, jail_dir_fd, "/etc/passwd", &how, sizeof(how));
/* opens <jail_dir>/etc/passwd, not /etc/passwd */

Internally the kernel treats dirfd as / for the duration of the lookup: absolute symlinks are interpreted relative to it, and .. at the virtual root stays at the virtual root, exactly like /.. is / on a real system. Which means you can hand it user paths with a leading slash without stripping anything first.

The difference from RESOLVE_BENEATH: RESOLVE_BENEATH returns an error on escape. RESOLVE_IN_ROOT silently clamps. Which one you want depends on whether you trust the path to be well-formed (use RESOLVE_IN_ROOT when serving user-supplied absolute paths that should be jail-relative; use RESOLVE_BENEATH when you expect clean relative paths and want to audit anything that tries to escape).

One caveat: magic symlinks (/proc/self/fd/..., /proc/self/exe) can still point outside the RESOLVE_IN_ROOT boundary unless you also pass RESOLVE_NO_MAGICLINKS. They’re a separate category from regular symlinks in the kernel’s resolution code.

The other flags

RESOLVE_NO_SYMLINKS refuses all symlinks anywhere in the path, including the final component, magic or otherwise (it implies RESOLVE_NO_MAGICLINKS). Returns ELOOP. The blunt instrument, for when you want the actual inode and not a pointer to one.

RESOLVE_NO_XDEV refuses to cross mount points, and that includes every bind mount. Useful when dirfd is a local filesystem and you don’t want path resolution to walk onto an NFS export or onto whatever some admin bind-mounts over your data directory six months from now. Returns EXDEV.

RESOLVE_NO_MAGICLINKS refuses procfs-style magic symlinks (/proc/*/fd/N, /proc/*/exe, etc.) without refusing regular symlinks. Returns ELOOP. Magic links don’t point at paths; they resolve against kernel object references, which is exactly why open("/proc/self/fd/3") can land you somewhere the namespace says you shouldn’t be. This is the surgical option.

RESOLVE_CACHED (Linux 5.12, not 5.6) is the one that surprises people: it fails unless every path component is already in the kernel’s lookup cache, i.e., if the kernel would have to go to disk, the network, or anything else that might sleep. Returns EAGAIN. This is useful in latency-sensitive hot paths where you’d rather retry later (or treat the miss as a sign the path is weird) than block on a directory lookup. It’s not a security flag; it’s a scheduling hint.

Combining flags

The flags compose. RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS is the paranoid archive extractor. RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS is a more permissive container-like sandbox that allows regular symlinks but blocks /proc escape tricks. RESOLVE_BENEATH | RESOLVE_NO_XDEV confines resolution to one filesystem subtree. RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS is the sane default for serving static files out of a document root.

The one pair that doesn’t compose is RESOLVE_BENEATH | RESOLVE_IN_ROOT. Error-on-escape and clamp-on-escape are contradictory instructions, and the kernel answers with EINVAL. Pick one.

The errors you get back are predictable:

  • EXDEV: tried to escape the dirfd boundary, or crossed a mount with RESOLVE_NO_XDEV
  • ELOOP: symlink when RESOLVE_NO_SYMLINKS or RESOLVE_NO_MAGICLINKS blocked it
  • EAGAIN: RESOLVE_CACHED needed a non-cached lookup, or RESOLVE_BENEATH/RESOLVE_IN_ROOT couldn’t prove a .. didn’t escape because something was renaming directories underneath it. Retry; if it keeps happening, someone is poking at you
  • EINVAL: unknown flag, mutually exclusive flags, or mode set without O_CREAT/O_TMPFILE
  • ENOSYS: kernel older than 5.6
  • E2BIG: your open_how struct is larger than the kernel knows about and the extra bytes aren’t zero (newer headers, older kernel)

Checking support, and the glibc situation

There is no glibc wrapper. You will read, including on this site at one point, that glibc 2.36 added openat2() to <fcntl.h>. It did not. The man page says flatly that glibc provides no wrapper and you use syscall(2). Believe the man page. So you write the four-line wrapper yourself:

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

#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);
}

<linux/openat2.h> comes from a 5.6+ kernel-headers package. The syscall number is 437 on x86-64, and also on arm64 and riscv64; anything else, read asm/unistd.h instead of trusting a blog.

To find out whether the box in front of you actually enforces this, without a compiler:

uname -r   # need 5.6+; 5.12+ for RESOLVE_CACHED

python3 - <<'EOF'
import ctypes, errno

SYS_openat2     = 437          # x86-64
RESOLVE_BENEATH = 0x08
O_PATH          = 0x200000     # x86-64

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

libc  = ctypes.CDLL(None, use_errno=True)
dirfd = libc.open(b'/tmp', O_PATH)
how   = OpenHow(flags=O_PATH, mode=0, resolve=RESOLVE_BENEATH)

# Try to walk out of /tmp. Should fail with EXDEV.
fd  = libc.syscall(SYS_openat2, ctypes.c_int(dirfd), b'../etc/passwd',
                   ctypes.byref(how), ctypes.c_size_t(ctypes.sizeof(how)))
err = ctypes.get_errno()
print(f'escape attempt: fd={fd}, errno={err} ({errno.errorcode.get(err)})')
print('RESOLVE_BENEATH working:', fd == -1 and err == errno.EXDEV)
EOF

ENOSYS means the kernel predates 5.6. EXDEV means you’re in business.

FreeBSD got there first, again

FreeBSD 12.0, December 2018: O_RESOLVE_BENEATH as a flag to the regular open(2) family. Linux 5.6 shipped RESOLVE_BENEATH in March 2020, sixteen months later.

The implementations differ. FreeBSD added a flag to the existing open(2) syscall. Linux added a new syscall with a versioned struct. The Linux approach handles future extension better (add fields to open_how, gate on E2BIG); the FreeBSD approach was simpler to deploy. Both prevent the same class of traversal bugs.

For the RESOLVE_IN_ROOT equivalent on FreeBSD, you’re in Capsicum territory: capability mode plus cap_rights_limit() on directory fds, which is a broader confinement model than a single open flag. It’s more powerful and considerably more work to set up. openat2 with RESOLVE_IN_ROOT is narrower but trivially deployable.

The minimum viable hardening

If you write anything that opens files whose paths come from untrusted input (user uploads, archive members, config that references external paths), the pattern is:

int base = open(trusted_root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (base < 0) { err(1, "open base dir"); }

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

long fd = syscall(SYS_openat2, base, untrusted_path, &how, sizeof(how));
if (fd < 0) {
    if (errno == ENOSYS) {
        /* kernel < 5.6: fall back to manual validation or reject */
    } else if (errno == EXDEV || errno == ELOOP) {
        /* path escapes: reject */
    } else {
        /* ENOENT, EACCES, etc.: handle normally */
    }
}

The fallback on ENOSYS matters. Anything shipping today should handle a 5.x kernel that predates 5.6. Either validate paths manually (check for .. components and symlinks, which is a correctness minefield, or just reject non-simple-relative paths), or document that the feature requires 5.6+.

On systems that have it, RESOLVE_BENEATH costs nothing. The kernel does the check during the same path walk it would do anyway. The security is free.

The full pattern for a static file server

Same idea, wired up the way you’d actually ship it: open the docroot once at startup, keep the fd, run every request path through openat2.

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

#ifndef SYS_openat2
#define SYS_openat2 437
#endif

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 || errno == ELOOP) {
            /* Escape attempt or refused link: log it and 403. */
            fprintf(stderr, "path rejected: %s\n", user_path);
            errno = EACCES;
        }
        return -1;
    }
    return fd;
}

The docroot is opened O_PATH: you only need it as an anchor, never to read it, so don’t ask for more than that. O_NOFOLLOW refuses a symlink in the final component with ELOOP, which RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS alone would happily follow as long as it stayed in-tree. Intermediate in-tree symlinks still resolve. If you don’t want those either, swap in RESOLVE_NO_SYMLINKS and drop the pretense.

What this does not do

openat2() resolves paths. That’s it. It does not restrict what the process does after it has the file descriptor, and it does nothing for the next open() some other part of your code makes without it. It’s per-call, opt-in, and only as good as your discipline about routing every untrusted path through it.

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. If you want to restrict which syscalls reach the kernel at all, add seccomp-BPF. They stack cleanly: openat2() for the fine-grained security-critical opens, landlock for the outer boundary, seccomp for the syscall surface.


The seccomp-bpf post covers syscall filtering, which is complementary: openat2 constrains where you open, seccomp constrains which syscalls the process can call at all. The landlock post is the process-wide version of what RESOLVE_BENEATH does per-open. The pidfd post is the same era of kernel work: new syscalls built to close the races the original POSIX interfaces left wide open. The fanotify post covers the access-control side of fanotify, which can enforce similar policies at the VFS layer but requires a privileged daemon rather than being self-contained in the sandboxed process.