Safe Path Traversal
A primary-source survey of how serious systems open, walk and delete filesystem paths without being redirected by whoever else can write to the tree — thirty years of it, from the paper that named TOCTTOU in 1996 to Linux's openat2, Go's os.Root, Rust's CVE-2022-21658 rewrite and libpathrs. Nine papers, five kernels, nine libraries and runtimes, three bodies of talks and reporting, and three runnable examples that pin the facts a CI run can check.
The evidence base for sparkles.base.dir_handle — the directory-handle module that sparkles:test-utils' TmpFS and the fileset walker (FSD5–FSD7) will be built on — and the reason that module's design was sent back for this survey first.
This survey answers eight questions:
- Is TOCTTOU a bug in programs or in the API? In the API. Bishop & Dilger proved it undecidable to find statically; Wei & Pu counted 224 dangerous syscall pairs; Watson showed even an in-kernel wrapper cannot make a path-string check atomic. See concepts § the race.
- Can user space win the race by being fast? No. The k-race was broken by filesystem mazes; hardness amplification was broken by a name-cache complexity attack. Every probabilistic defense "pulls the performance characteristics of the OS into the trusted computing base". See comparison § consensus.
- What survived? Walking once from a handle:
safe_openin 2010,openat2in 2020, and the component walk every library keeps as a fallback. See axis 1. - Do the kernels agree on
..? No — Linux allows a..that stays beneath, FreeBSD refuses even a temporary escape, Windows resolves it in the parser. See axis 2 and the flag matrix. - Beneath or no-symlinks? Two different guarantees; the portable floor is the stricter one. See axis 3.
- How do the libraries fall back, and what do they get wrong? Cache failure, never success; never downgrade per lookup — and cap-std does. See axis 4.
- What does everyone do about deletion? The same thing, since 2022: fd-relative, never descend a link, list from the handle. See axis 6 and
fd-remove-tree.d. - What should
sparkles.base.dir_handledo? See the delta table.
Master catalog
| Subject | Category | Year | Primitive / contribution | Link |
|---|---|---|---|---|
| Bishop & Dilger | paper | 1996 | Names the race; paths are indirect pointers, descriptors direct | → |
| Dean & Hu / Borisov et al. | paper | 2004/2005 | The k-race defense and the filesystem maze that broke it | → |
| Wei & Pu | paper | 2005 | 224 check/use pairs; live attacks on rpm, vi, emacs | → |
| Watson | paper | 2007 | Syscall-wrapper races; check and use must share a handle | → |
| Tsafrir et al. | paper | 2008 | Component-at-a-time hardness amplification; "openat would solve this" | → |
| Cai, Gui & Johnson | paper | 2009 | Name-cache complexity attack; probabilistic defenses declared dead | → |
| Chari, Halevi & Venema | paper | 2010 | safe_open: per-component openat walk with an explicit trust model | → |
| Payer & Gross | paper | 2012 | DynaRace: user-space identity cache, termination on mismatch | → |
| Raducu, Rodríguez & Álvarez | paper | 2022 | Systematic review of 41 defenses; inode-reuse table | → |
Linux openat2 | kernel | 2020 | RESOLVE_BENEATH / IN_ROOT / NO_SYMLINKS / NO_MAGICLINKS / NO_XDEV | → |
| Linux procfs and magic links | kernel | 2019–2026 | Why /proc is the escape hatch; private procfs via fsopen | → |
| FreeBSD and OpenBSD | kernel | 2021+ | O_RESOLVE_BENEATH, Capsicum, O_EMPTY_PATH; unveil | → |
| Darwin | kernel | 2020+ | O_NOFOLLOW_ANY family — present, and unused by every consumer surveyed | → |
| Windows NT | kernel | — | NtCreateFile + RootDirectory, OBJ_DONT_REPARSE, handle-based delete | → |
| libpathrs | library | 2019–2026 | openat2 resolver + O_PATH fallback; the CVE catalogue | → |
| filepath-securejoin | library | 2017–2026 | Lexical SecureJoin → OpenInRoot; the runc lineage | → |
Go os.Root | runtime | 2025 | Per-component walk with restart-from-root on ..; three platform tiers | → |
| Rust std | runtime | 2022 | remove_dir_all rewrite on Unix and NT; fs::Dir tracking issue | → |
| rustix | binding | 2021–2026 | Every *at, raw openat2, Dir::read_from re-open; I/O safety | → |
| cap-std | library | 2020–2026 | Capability Dir; ancestor-stack ..; behaviour-probed O_RESOLVE_BENEATH | → |
| openat / openat-ext | binding | 2016–2023 | The minimal dirfd wrapper and its deprecation | → |
gnulib fts | walker | 2005+ | FTS_CWDFD, the fd ring, the openat emulation ladder; coreutils rm | → |
CPython shutil | walker | 2012+ | rmtree.avoids_symlink_attacks; lstat/fstat identity without O_NOFOLLOW | → |
| Aleksa Sarai's talks | talks | 2019–2025 | Path resolution in container runtimes, LPC → LCA → ASG → "in the trenches" | → |
LWN openat2 coverage | articles | 2014–2026 | O_BENEATH to openat2 to "the difficulty of safe path traversal" | → |
| Forshaw's Windows symlinks | talk+tools | 2015–2021 | What an attacker plants between check and use on NT | → |
Taxonomy
By where the walk happens
| Where | Subjects |
|---|---|
| Kernel, whole path | openat2, FreeBSD, Windows OBJ_DONT_REPARSE, Darwin (unused) |
| User space, per component | Chari, Tsafrir, Go, Rust, cap-std, libpathrs, gnulib, CPython |
| User space, by name | classic SecureJoin (securejoin), Go on js/Plan 9, CPython on Windows |
By .. policy
| Policy | Subjects |
|---|---|
| Rejected lexically | Go (escape count), Windows (parser), the examples |
| Popped from an ancestor stack | cap-std, gnulib fd ring |
| Restart from root | Go |
| Re-verified through procfs | libpathrs, securejoin |
Kernel-checked, EAGAIN on race | openat2; FreeBSD refuses outright |
By fallback honesty
| Behaviour | Subjects |
|---|---|
| Publishes which resolver is live | CPython |
| Caches failure only, never downgrades | libpathrs, securejoin |
| Downgrades silently | cap-std (after 4 EAGAIN), gnulib |
| Decided at compile time | Rust std |
Milestones
| When | What |
|---|---|
| 1996 | Bishop & Dilger name TOCTTOU binding flaws |
| 2004 | Dean & Hu propose the k-race |
| 2005 | Borisov et al. break it with filesystem mazes; Wei & Pu enumerate 224 pairs |
| 2006 | coreutils 6.0 moves du, chmod, chown onto an openat-based fts (fts) |
| 2007 | Watson: syscall-wrapper races |
| 2008 | Tsafrir et al.: hardness amplification; POSIX.1-2008 standardizes openat |
| 2009 | Cai et al. break it again |
| 2010 | Chari et al. publish safe_open |
| 2012 | CPython 3.3 rmtree goes fd-based (cpython); DynaRace |
| 2014 | Drysdale's O_BENEATH for Capsicum-on-Linux (LWN) |
| 2016 | tailhook openat crate (crates) |
| 2019 | runc CVE-2019-5736 via /proc/self/exe (procfs); Sarai's LPC talk (talks) |
| 2020-03 | Linux 5.6 ships openat2; cap-std begins (capstd) |
| 2021 | FreeBSD 13 O_RESOLVE_BENEATH (bsd) |
| 2022-01 | Rust CVE-2022-21658 remove_dir_all rewrite (rust); Raducu survey |
| 2024 | runc CVE-2024-21626 (leaked /proc fd); securejoin OpenInRoot (securejoin); libpathrs at ASG (talks) |
| 2025-02 | Go 1.24 os.Root (go) |
| 2026-01 | LWN: "The difficulty of safe path traversal" (lwn) |
Reading paths
- I am designing
sparkles.base.dir_handle: comparison →openat2→ Go → libpathrs → Windows → Darwin → Rust std (deletion) → the examples. - I want the theory: concepts → Bishop → Dean & Hu / Borisov → Cai → Chari → Raducu.
- I want to know what an attacker does: Forshaw → procfs → Sarai's talks → Wei & Pu.
Sources
Every deep-dive carries its own Sources block. Repositories were read from local clones at the commits pinned in each citation; papers from the publisher PDFs; LWN and vendor documentation by URL. Nothing here is written from memory.