# Symlink escape past validated root

Path checked lexically or by leaf name, then symlinks resolved without re-checking containment under the allowed root

_Area: Web injection and code execution. Seen in 4 disclosed reports; latest 2026-10-27. Updated 2026-11-01._

## How it works

A path is validated as text: separators, `..`, drive prefixes, or a target prefix are checked, and the string is joined to a root. That check proves nothing about where the path actually lands, because the filesystem resolves symlinks (and archive member types) after the check. If the code then opens the path, or extracts through it, the real target can sit outside the root. The broken assumption is "a path that looks contained is contained." Containment only holds if the resolved real path is re-checked against the real root, or if resolution is refused entirely.

## Where it shows up

- Lexical joins (`abspath`, `filepath.Join`) followed by open, with no real-path containment check [E3](https://bugbountylibrary.com/r/ghsa%3AGHSA-f833-7jw8-xwrv.md)[E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).
- A resolver like `EvalSymlinks` whose output is returned to callers unchecked [E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).
- Archive extractors that whitelist symlinks by target prefix, then write regular files through them [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md).
- Extraction or decryption running before the feature flags that gate applying the data [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md).
- Member-name and link-target checks that ignore member type, mode, devmajor/devminor, uid/gid [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md).
- A containment helper that exists for one sink (archive extraction) but is not used by general read paths [E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).

## How to review

Find every place a user-influenced path is turned into a file operation. For each, ask: is the check done on the string or on the resolved object? The invariant that must hold: after full resolution (symlinks, and for archives member type), the real path is still under the real root, and this is enforced at the sink, not at the input. Compare call sites against the codebase's own scoped helper (e.g. `validate_path(required_root=...)`) and flag any that use a bare lexical join [E3](https://bugbountylibrary.com/r/ghsa%3AGHSA-f833-7jw8-xwrv.md). For archives, enumerate every member attribute the code ignores and check the runtime's default extraction filter [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md). Check ordering: does extraction happen before the flags that decide whether to apply it? [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md)

## How finders spotted it

- Read a prior traversal fix, saw it only inspected names and link targets, then checked runtime filter semantics to confirm special member types are honored [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md).
- Noticed extraction/decryption preceded the restore flags, and the extractor allowed symlinks by target prefix, so a write could follow the link [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md).
- Saw a CVE fix validate name components only, then compared call sites: lexical join plus raw open versus a sibling reader using the scoped validator [E3](https://bugbountylibrary.com/r/ghsa%3AGHSA-f833-7jw8-xwrv.md).
- Read the read path, saw `EvalSymlinks` output returned with no containment check, while the project already had a containment guard for archive extraction [E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).

## Variants seen

- Archive member type abuse: device nodes and FIFOs created despite name checks [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md).
- Symlink-plus-file entry in a restore archive writing into a live config tree [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md).
- Separator-free symlink name bypassing a name guard in a corpus reader [E3](https://bugbountylibrary.com/r/ghsa%3AGHSA-f833-7jw8-xwrv.md).
- General file read following symlinks out of a user's scope, reachable unauthenticated via public shares [E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).

## Impact and severity

Arbitrary file read outside the served root, including `/etc/passwd` and secrets, unauthenticated via public shares and cross-tenant in multi-user setups [E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md). Root-run extraction yields raw host disk access [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md). Persistent config injection into the live Nginx tree, risking DoS on reload [E2](https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md). Impact rises with reach (public endpoints, multiple sinks) and with privilege of the process [E1](https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md)[E4](https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md).

## Evidence
- E1: https://bugbountylibrary.com/r/ghsa%3AGHSA-fr26-jjhm-638c.md
- E2: https://bugbountylibrary.com/r/ghsa%3AGHSA-p8v3-89rh-jxc7.md
- E3: https://bugbountylibrary.com/r/ghsa%3AGHSA-f833-7jw8-xwrv.md
- E4: https://bugbountylibrary.com/r/ghsa%3AGHSA-mgqf-5mf5-prfj.md
