Taking a filename from a user and opening it inside your own directory is one of the oldest jobs in software. Archive extractors do it, file servers do it, even template engines do it. And nearly everyone writes the same two lines: join the path onto the root, resolve it with realpath, verify the result starts with the root. If it doesn't, reject.
In mid-October I went through the behaviour of openat2 itself in a separate article, and I ran this race there too: the classic pattern that calls fstatat and then openat returned the attacker's file on 30.7% of its successful opens inside a 50 µs window. So I already know "check then open" is broken — that part is not new.
This article asks something else: which check actually gets written in the field, and what does it do? Few people write the fstatat pattern by hand; what everyone writes is realpath plus a prefix comparison. And that check looks spotless in a static test — which is probably why nobody replaces it.
I tried six defenses against nine paths. In the static table realpath came out spotless — it stopped all six escapes. Then I ran the same check while the directory structure moved underneath it: on 24,378 of 200,000 turns it read the file outside the cage. In that same race, RESOLVE_IN_ROOT returned zero.
The setup
Measurements were taken on this blog's own server: Ubuntu 24.04, kernel 6.8.0-147-generic, 18 vCPUs, gcc 13.3.0. All of it as an unprivileged user inside a temporary directory from mktemp -d; nothing persistent on the server was touched.
The cage: kok/ is the area we permit, disari/ is what must not be read.
kok/ok.txt -> "KAFES-ICI"
kok/sub/ok2.txt -> "KAFES-ICI2"
kok/ici -> sub/ok2.txt # symlink that stays inside (legitimate)
kok/mutlak -> /etc/hostname # absolute symlink
kok/goreli -> ../disari/sir.txt # relative escape
kok/atla -> ../disari # escape to a directory
kok/sihir -> /proc/self/root # magic link
disari/sir.txt -> "DISARIDAKI-SIR"
Three of the nine paths are legitimate — they have to be accepted. I put that distinction in deliberately, because how many escapes a defense stops says nothing on its own; a function that rejects everything also stops all six. The real question is whether it does both jobs.
The legitimate three: ok.txt (plain file), sub/../ok.txt (a .. that turns back inside), ici (a symlink that stays in the cage). The six escapes: ../disari/sir.txt, /etc/hostname, mutlak, goreli, atla/sir.txt, sihir/etc/hostname.
Six defenses
First, the filter everyone writes once:
/* D1: reject if it contains ".." */
if (strstr(path, "..")) { errno = EACCES; return -1; }
return openat(dfd, path, O_RDONLY);
Second, the check serious code writes — the real defendant here:
/* D2: realpath + prefix comparison */
snprintf(joined, sizeof joined, "%s/%s", ROOT, path);
if (!realpath(joined, resolved)) return -1;
size_t rl = strlen(ROOT);
if (strncmp(resolved, ROOT, rl) != 0 ||
(resolved[rl] != '/' && resolved[rl] != '\0')) {
errno = EACCES; return -1;
}
return open(resolved, O_RDONLY);
The resolved[rl] test matters: without it, /srv/data-gizli passes a /srv/data prefix. A common bug, but not this article's subject; the version above is the correct one.
Third, the route taken by people who say "I turned symlinks off": openat(dfd, path, O_RDONLY | O_NOFOLLOW). The remaining three use openat2: RESOLVE_BENEATH, RESOLVE_IN_ROOT, and RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_XDEV.
The static table
With the directory structure held still, 6 × 9 = 54 opens:
| Defense | Escapes blocked | Legitimate opened |
|---|---|---|
D1 .. filter |
1/6 | 2/3 |
| D2 realpath + prefix | 6/6 | 3/3 |
D3 openat + O_NOFOLLOW
|
2/6 | 2/3 |
D4 RESOLVE_BENEATH
|
6/6 | 3/3 |
D5 RESOLVE_IN_ROOT
|
6/6 | 3/3 |
D6 IN_ROOT + NOMAGIC + NOXDEV
|
6/6 | 3/3 |
Since a summary can mislead, here is the raw table too — which error code came from where is about to matter:
| Defense | ok.txt |
sub/../ok.txt |
ici |
../disari/sir.txt |
/etc/hostname |
mutlak |
goreli |
atla/sir.txt |
sihir/... |
|---|---|---|---|---|---|---|---|---|---|
D1 .. filter |
OPEN | EACCES |
OPEN | EACCES |
OPEN | OPEN | OPEN | OPEN | OPEN |
| D2 realpath + prefix | OPEN | OPEN | OPEN | EACCES |
EACCES |
EACCES |
EACCES |
EACCES |
EACCES |
D3 O_NOFOLLOW
|
OPEN | OPEN | ELOOP |
OPEN | OPEN | ELOOP |
ELOOP |
OPEN | OPEN |
D4 BENEATH
|
OPEN | OPEN | OPEN | EXDEV |
EXDEV |
EXDEV |
EXDEV |
EXDEV |
EXDEV |
D5 IN_ROOT
|
OPEN | OPEN | OPEN | ENOENT |
ENOENT |
ENOENT |
ENOENT |
ENOENT |
ENOENT |
D6 IN_ROOT+NOMAGIC+NOXDEV
|
OPEN | OPEN | OPEN | ENOENT |
ENOENT |
ENOENT |
ENOENT |
ENOENT |
ENOENT |
Every bold OPEN is a leak: in that cell the program read the contents of a file outside the cage.
The .. filter loses at both ends. It stops only one of six escapes — because there is no .. inside a symlink, and none in the absolute path /etc/hostname either. Meanwhile it rejects the legitimate sub/../ok.txt with EACCES. So it lets the attacker through and annoys the user. Managing that combination is genuinely hard.
O_NOFOLLOW keeps its promise, but the promise is small: it protects only the final component. mutlak and goreli are symlinks themselves, so they get ELOOP — but atla/sir.txt, where atla is a symlink to a directory, opens cleanly and reads the secret outside. O_NOFOLLOW is not a scoping flag; once again, something the manual never hides, yet it keeps getting used as scoping in real code. On top of that it drops the legitimate ici link with ELOOP.
That the last two rows look alike but return different error codes is no accident either — BENEATH says EXDEV, IN_ROOT says ENOENT. I will come back to that difference shortly, because it is the most misleading part of the table.
And here is the discomfort in the middle of it: D2 produces exactly the same result as the openat2 flags. Six escapes closed, three legitimate paths open. Anyone reading this far would say "so realpath is enough," and they would not be unreasonable. The table says they are right.
Because the table is incomplete.
The real test
All 54 opens above happened in a directory tree where nothing moved. In production, directory trees move. Your archive extractor gets another request into the same directory while writing a file; a backup process relocates something; or none of that happens and the person on the other side moves the directory on purpose.
realpath and open are two separate calls. There is a moment between them.
To measure that window I wrote a second probe and a separate layout:
kok/alan/hedef.txt -> "KAFES-ICI-HEDEF"
kok/.yem -> ../disari # the symlink to be swapped in
disari/hedef.txt -> "DISARIDAKI-SIR"
The main thread keeps trying to open the same path; a side thread keeps atomically swapping the alan directory with a symlink pointing outside, using renameat2(RENAME_EXCHANGE). The atomic swap is critical here: there is no half-finished state, so what the numbers below show is not an implementation bug but a structural hole in the check itself.
The whole rig is this much — the part you would copy to try it in your own code:
/* side thread: atomically swap the directory with a symlink */
static void *takasci(void *_) {
while (!dur)
if (syscall(__NR_renameat2, dfd, "alan",
dfd, ".yem", RENAME_EXCHANGE) == 0) takas++;
return NULL;
}
/* main loop: open the same path with both defenses, read the content */
for (long i = 0; i < N; i++) {
int f = d2("alan/hedef.txt"); /* realpath + prefix */
if (f >= 0) { a2++; if (sizdi(f)) s2++; } else h2++;
f = d5("alan/hedef.txt"); /* RESOLVE_IN_ROOT */
if (f >= 0) { a5++; if (sizdi(f)) s5++; } else h5++;
}
sizdi() reads from the opened descriptor and checks whether the content starts with DISARIDAKI. So the counter is not counting "the open succeeded" but the wrong file's contents actually reached my hands. My first probe did not draw that distinction and reported the rate higher than it was.
I ran both D2 and D5 in the same run, 200,000 turns:
| Defense | Opened | Errors | Leaked outside |
|---|---|---|---|
| D2 realpath + prefix | 90,901 | 109,099 | 24,378 |
D5 RESOLVE_IN_ROOT
|
88,565 | 111,435 | 0 |
The swap counter read 314,729 over the same period.
24,378 is 12.2% of all attempts. But that is not the ratio to look at: of the 90,901 files D2 successfully opened, 26.8% were outside the cage. So after the check said "fine, this path is safe," roughly one open in four returned the wrong file.
This is not an estimate or a theoretical window; I read the contents, and they said DISARIDAKI-SIR. 24,378 times.
On the RESOLVE_IN_ROOT side the number is zero. The reason is simple, and that is exactly why it holds: in openat2 the check and the open are not separate steps. The kernel validates the scope while it resolves the path, and never hands control back to you in between. There is no window to miss.
Look at D5's 111,435 errors as well — more than D2's 109,099. There is a cost here and no point hiding it: opens that land on the swap moment return errors, and some of them get EAGAIN from scope ambiguity. I did not break the errors down, so I cannot say how many of those 111,435 were ENOENT and how many EAGAIN. EAGAIN got its own measurement in the October piece: under a synthetic load generating thousands of renames per second it reached one open in five — that figure is not a ceiling, and it is not a rate measured on a real server either. Either way, moving to openat2 is not free security but a trade that requires you to write a bounded retry budget. On the other side of that trade sit 24,378 leaks.
IN_ROOT does not reject, it remaps
The October article's table has a row where opening /etc/hostname with RESOLVE_IN_ROOT returned ENOENT. I recorded the result but never showed why it was ENOENT — and that is exactly where the two flags part ways.
It was ENOENT because there was no directory named etc inside the cage. That was the easy case.
I put etc/hostname in the cage and tried again. The server's real /etc/hostname reads mustafaerbay-vps3; into the fake copy inside the cage I wrote SAHTE-HOSTNAME-KAFESTEN:
| Flag | Path | Result |
|---|---|---|
RESOLVE_BENEATH |
/etc/hostname |
DENIED EXDEV
|
RESOLVE_IN_ROOT |
/etc/hostname |
OPEN → SAHTE-HOSTNAME-KAFESTEN
|
RESOLVE_BENEATH |
../../etc/hostname |
DENIED EXDEV
|
RESOLVE_IN_ROOT |
../../etc/hostname |
OPEN → SAHTE-HOSTNAME-KAFESTEN
|
The two flags do not do the same job. BENEATH, in the manual's words, does not permit resolution to leave the directory given by dirfd, and treats the attempt as an error. IN_ROOT treats dirfd as the root — so it does not reject the escape, it remaps it inward. Whatever chroot(2) does, but for a single open.
So that October ENOENT did not mean "you cannot leave"; the kernel was saying it looked for kok/etc/hostname and did not find it. The moment you put that file in the cage, the same call returns a file instead of an error.
In practice this means the following. If you are writing an archive extractor, IN_ROOT is exactly what you want: a /etc/passwd entry inside a tar quietly becomes kok/etc/passwd, which is how container images work. But if your case is "the user gives me a path inside my data directory," IN_ROOT is dangerously quiet: when the user asks for /etc/hostname they do not get an error, they get a different file. If an attacker can drop files into the cage — which is precisely what they can do in an archive extractor — they can redirect any path they like to their own content.
My preference: BENEATH for APIs that take a path from the user. I want the error. Not a silently different file.
The cage does not know what it holds
There is also a dimension scoping takes no interest in. I put a FIFO into the cage with mkfifo and opened it with RESOLVE_IN_ROOT:
RESOLVE_IN_ROOT boru -> OPEN (type=FIFO)
RESOLVE_IN_ROOT ok.txt -> OPEN (type=regular file)
The cage held — the pipe really is inside it. But had I not written O_NONBLOCK, the program would be hanging there waiting for a writer. Scoping flags check where you went, not what you opened. Confining the path to the cage does not mean you opened a regular file.
The kernel has an answer for this, a new one: OPENAT2_REGULAR. The patch written by Dorjoy Chowdhury and taken by Christian Brauner on 21 May 2026 returns EFTYPE when the path is not a regular file. I could not find the flag in v7.1's header; it is there in v7.2 — and today's mainline is v7.3. On my own 6.8 I got EINVAL, as expected: the old kernel does not know the flag and says so rather than ignoring it silently. That distinction and the full list of error codes got their own section in the October piece; I am not repeating it here.
What it costs, and what to do below 5.6
I expected the safe option to be the expensive one. The numbers said the opposite. I opened a four-level path (a/b/c/d/f.txt) 300,000 times, after a warm-up round, in two separate runs:
| Method | Run 1 | Run 2 |
|---|---|---|
openat (unscoped) |
7,311 ns | 7,131 ns |
| realpath + prefix | 32,579 ns | 33,244 ns |
RESOLVE_BENEATH |
7,705 ns | 7,833 ns |
RESOLVE_IN_ROOT |
7,720 ns | 7,307 ns |
realpath is roughly 4.5 times more expensive, and the reason is unsurprising: it resolves the path fully once, then makes the kernel resolve the same path a second time. Two full path resolutions plus an lstat per component.
Quoting one number for the scoping flags would be wrong, because the two flags do not sit in the same place. Unscoped openat wanders between 7,131 and 7,311. IN_ROOT wanders between 7,307 and 7,720 — the ranges overlap by four nanoseconds, so I cannot claim a difference there. BENEATH, though, runs 7,705 to 7,833: both runs sit above the unscoped ceiling, roughly 7%. That is a consistent gap, but I have two runs; it is not enough data to write "BENEATH is seven percent slower." What I can say: the cost of the scoping flags is at most a few percent, and in this measurement BENEATH looks slightly more expensive than IN_ROOT.
Either way, the trade was not "security or speed" as I assumed. The leaky defense turned out to be the expensive one by a wide margin.
On portability I have to be straight: openat2 arrived in 5.6, that is March 2020. On kernels below it — or outside Linux — these flags do not exist, and you have to walk the path component by component with O_NOFOLLOW at every step; that is what userspace libraries do. I did not measure that route, so I will not claim it gives equivalent protection.
There is a trap here that is easy to skip: the kernel version is not enough. If you run inside a container, the seccomp profile may not know openat2 — a known situation on older Docker and libseccomp versions — and the call comes back EPERM or ENOSYS. So base your capability detection on a real probe at startup rather than a version number: call it once, fall back to emulation on ENOSYS/EPERM, and make that fallback increment a counter rather than happen silently. That is the only way you notice the machine with a new kernel and an old profile.
You do not have to write the emulation yourself either: os.Root in Go's standard library and filepath-securejoin cover it on the Go side, libpathrs on the C and Rust side. Whichever you pick, if you provide scoping through emulation in your own code, run the race probe above against that emulation too. My realpath check also looked correct when it was written.
The code outside the cage
Finally, something none of this article's measurements cover: the path you open with openat2 is safe, but your program does not only open files.
There is a live example. CVE-2026-41579 (CVSS 3.3, low), closed in runc in June 2026, is exactly this: the advisory says the mount-target lookup "correctly scopes the lookup inside the container." What opens the hole is not the lookup but the os.Remove and os.Symlink calls in setupPtmx and setupDevSymlinks, made on strings built with filepath.Join. If the image's /dev is a symlink, files named ptmx on the host can be deleted — and on the second arm, six fixed-name symlinks (core, fd, ptmx, stdin, stdout, stderr) can be created in an existing host directory. Docker is not affected because it masks /dev with a read-only layer; podman and containerd are. Fixed in 1.3.6, 1.4.3 and 1.5.0-rc.3.
So a scoped open is a component, not a policy. You need to resolve the path once with openat2 and then continue through the descriptor: unlinkat, symlinkat, fstatat all take a dirfd. Every line that joins strings and returns to a path leaves the guarantee you earned right there. The title of the runc fix says as much: make the /dev setup descriptor-based.
In practice that means this transformation:
/* BEFORE: the open is scoped, what follows is not */
int fd = sys_openat2(dfd, rel, &how); /* safe */
snprintf(p, sizeof p, "%s/%s", kok, rel);
unlink(p); /* NOT safe */
/* AFTER: continue through the descriptor */
int dir = sys_openat2(dfd, dirname_of(rel), &how_dir); /* O_PATH|O_DIRECTORY */
unlinkat(dir, basename_of(rel), 0);
In the second version there is no second path resolution; unlinkat starts from a descriptor whose scope is already validated. Reduced to one sentence: resolve the path once, then never return to the path.
A decision frame
-
Do not write a
..filter. In the measurement it stopped one escape of six and rejected a legitimate path. It cannot do either job. -
Do not mistake
O_NOFOLLOWfor scoping. It protects only the final component; everything under a symlink to a directory walks right through. -
realpath+ prefix is correct on a static tree, not in a race. If you are certain the tree is yours and nobody moves it, it can stay. If you are not certain, the measured rate is 26.8%. -
Taking a path from the user?
RESOLVE_BENEATH. You want the escape as an error. -
Extracting an archive or image?
RESOLVE_IN_ROOT. You want the escape remapped inward — but know that this is a deliberate choice. -
Do not pass both scoping flags, you will get
EINVAL. Close magic links separately withRESOLVE_NO_MAGICLINKS; the manual says the scoping flags do this for now and that it may change. -
Write a bounded budget for
EAGAIN— three to five attempts — and if it runs out, reject the request and record it in a separate counter. Never retrying produces spurious failures when a neighbour is busy. -
Check the file type separately. Before v7.2 with
fstat; from v7.2 on withOPENAT2_REGULAR. -
Do not return to strings after the open. Use the
*atfamily. -
Hold your root descriptor somewhere and think about its lifetime. Opening a root per request burns descriptors; holding one long-lived means the root itself can be
renamed orumounted from under you. I prefer keeping the root as a long-livedO_PATHdescriptor and checking periodically that it is still the object I expect. -
Do not use cost as the excuse. In the measurement the scoping flags differ by at most a few percent; the expensive one is the
realpathversion (4.5 times).
Conclusion
In October I showed that openat2 closes the window between the check and the open. What this measurement taught me is where that window hides best: realpath plus a prefix comparison gave the right answer on all 54 opens. A static test suite will never go red on it — a single-threaded test assumes the world holds still while it runs. And the defense that looked best in the table turned out to be the most expensive one.
The second lesson is about IN_ROOT, and it struck me as the quieter one: it does not stop the escape, it remaps it inward. For anyone who can drop files into the cage, that means getting the file they want instead of an error. In code that takes a path from the user, what I want is the EXDEV that BENEATH gives me.
Put together, what I am left with is this: a check returning the right answer is not enough — there has to be nobody between the check and the action. What openat2 gives you is not a stricter rule; it is the absence of that gap.
Official Sources
- include/uapi/linux/openat2.h — RESOLVE_* and OPENAT2_REGULAR definitions
- openat2: new OPENAT2_REGULAR flag support — kernel commit
- openat2(2) manual page — RESOLVE_BENEATH and RESOLVE_IN_ROOT
- runc security advisory GHSA-xjvp-4fhw-gc47 (CVE-2026-41579)
- open(2) manual page — O_NOFOLLOW affects only the final component
Top comments (0)