Concepts
The vocabulary the deep-dives share. Each term is defined once here and grounded in the subject where it is most precisely stated; the deep-dives link back rather than redefine.
The race
TOCTTOU
Time of check to time of use: a program inspects a filesystem object by name, decides something, then acts on the name again — and the binding of name to object changed in between. Named and formalized by Bishop & Dilger (1996), who distinguish the check (access, stat, lstat) from the use (open, chmod, unlink) and observe that a name is not an object. Wei & Pu (2005) enumerate every check/use syscall pair; the count is 224.
Binding versus use
A path is a sequence of bindings (name → inode) walked left to right. A check establishes facts about the object at the end of one walk; a use starts a fresh walk. Nothing ties the two walks together, which is why every fix in this catalog is some way of walking once — in the kernel (openat2), or in user space one handle at a time (component walk).
Race window
The interval between check and use. Dean & Hu (2004) tried to make it statistically unwinnable (the k-race); Borisov et al. (2005) and Cai et al. (2009) showed the attacker can stretch it arbitrarily with a maze, so its length is the attacker's to choose, not the defender's.
k-race and hardness amplification
Repeating a check/use pair k times and requiring every repetition to agree, so the attacker must win 2k+1 consecutive races (Dean & Hu). Tsafrir et al. (2008) "amplify" this by walking the path one atom at a time so the defender's cache keeps each window tiny. Both are probabilistic defenses; both were broken by making the kernel's own lookup slow (Cai et al.). The field's conclusion — recorded by the systematic review — is that a defense must be race-free, not race-unlikely.
Filesystem maze
A chain of directories and symlinks, up to the kernel's symlink-expansion limit, arranged so that resolving one path takes a long, attacker-controlled time (Borisov et al.). Combined with atime polling or a name-cache hash-collision attack (Cai et al.) it lets an unprivileged process single-step a victim through its race window.
The fix
Directory handle (dirfd)
An open file descriptor (or NT HANDLE) on a directory, used as the origin of a relative lookup: openat(dirfd, "name", …). The handle names an inode, not a path, so a rename above it changes nothing and a symlink swap below it is visible as a symlink. Every library in the catalog is a wrapper around one (libpathrs, cap-std, Go os.Root, the openat crates, gnulib fts). On Windows the same object is OBJECT_ATTRIBUTES.RootDirectory (Windows NT).
Component walk
Resolving a relative path in user space one name at a time, each step an openat relative to the previous handle with O_NOFOLLOW | O_DIRECTORY (or the NT FILE_OPEN_REPARSE_POINT equivalent), so no step can follow a link and every step is atomic with respect to the handle before it. It is the fallback every library keeps for kernels without a scoped lookup (libpathrs's opath resolver, cap-std's manually module, Go's root_openat.go). The demonstration is examples/component-walk.d. Two costs: O(depth) syscalls, and a .. that must be handled in user space — see dot-dot.
Scoped lookup
A single in-kernel path resolution that refuses to leave a root: openat2 with RESOLVE_BENEATH or RESOLVE_IN_ROOT on Linux 5.6+, O_RESOLVE_BENEATH on FreeBSD. The kernel holds the locks a component walk cannot, so a .. can be checked against the root during the walk; when even the kernel cannot prove it (a concurrent rename), it fails closed with EAGAIN rather than guessing. Darwin's headers declare O_RESOLVE_BENEATH siblings that no surveyed consumer uses, and Windows has none; in practice both offer only a no-follow-any mode.
Beneath versus no-symlinks
Two different guarantees that are easy to conflate. Beneath (RESOLVE_BENEATH, O_RESOLVE_BENEATH): the result lies under the root, symlinks that stay under it are followed. No symlinks (RESOLVE_NO_SYMLINKS, Darwin O_NOFOLLOW_ANY, NT OBJ_DONT_REPARSE): no component may be a link at all, which is stronger and cheaper to emulate but refuses legitimate links. The flag matrix shows the two disagreeing on the same path. A portable API must pick one and say which; the catalog's comparison argues for no symlinks as the portable floor.
Dot-dot
.. is the component the kernel and user space disagree about. In-kernel scoped lookup allows a .. that stays beneath (and detects one that does not); a component walk cannot see whether .. escaped without re-checking the root, so every user-space resolver either rejects it lexically before the first syscall (Go, this catalog's examples) or tracks a component stack and re-verifies (cap-std, libpathrs). Rejecting it up front is the only choice whose behaviour is identical on every platform.
Magic link
A procfs symlink whose target is not a path but a kernel object: /proc/<pid>/fd/N, /proc/<pid>/root, /proc/self/exe. Following one can jump out of any root and re-open a file with different permissions — the runc CVE-2019-5736 mechanism. Refused by RESOLVE_NO_MAGICLINKS (implied by RESOLVE_NO_SYMLINKS and, in practice, by RESOLVE_BENEATH); the full account is procfs magic links.
O_PATH handle
A Linux descriptor that references an inode without opening it for I/O — "a location, not a file". Resolvers produce one, then re-open it through /proc/self/fd/N with the real flags, which is how libpathrs separates where from how. The re-open is itself a path lookup through procfs, which is why procfs must be trusted first.
Reparse point
The NTFS generalization of a symlink: a directory or file carrying a reparse tag (symbolic link, mount point / junction, AppExecLink, cloud placeholder…) that the object manager acts on during a parse. FILE_OPEN_REPARSE_POINT opens the point itself (final component only); OBJ_DONT_REPARSE refuses to parse through any. Forshaw's tools enumerate what an attacker can plant; Windows NT records what a defender must check.
Mount boundary
A bind mount or a mounted filesystem placed beneath a root changes what a path means without any symlink. RESOLVE_NO_XDEV refuses to cross one; elsewhere the only check is comparing st_dev (or NT FileIdInfo volume serial) before and after, which races.
fd-relative deletion
Removing a tree by opening each directory relative to its parent handle, listing it from the handle (fdopendir, NtQueryDirectoryFile) and unlinking each entry relative to it (unlinkat, FileDispositionInformationEx) — so a symlink planted mid-deletion is unlinked as a name, never descended. The shape of Rust's CVE-2022-21658 fix, CPython's rmtree and gnulib's fts; demonstrated by examples/fd-remove-tree.d.
Trust model
Chari, Halevi & Venema (2010) make explicit what every other design leaves implicit: a path is only as safe as the set of users who can modify each directory on it. Their safe_open tracks a per-component safe/unsafe bit, and once a component is writable by someone other than root and the caller, no later symlink, .. or multiply-linked file is accepted.
The systems, by what they are
| Term in this catalog | Meaning |
|---|---|
| Resolver | The code that turns a relative path plus a root handle into a handle (libpathrs, cap-std, Go) |
| Binding layer | Exposes the syscalls without choosing a policy (rustix, the openat crates) |
| Walker | Enumerates a tree from handles (gnulib fts, CPython fwalk) |
| Mechanism | What a kernel offers (Linux, BSD, Darwin, Windows) |