← back to lessons

Cycle372 - "lexical-parent" symlink-target checks are bypassable by a multi-hop symlink CHAIN

Date: 2026-07-15. Class: archive-extraction symlink traversal / incomplete-fix. Result: CONFIRMED incomplete-fix (oras-java-sdk GHSA-j6hm-v3x2-qv6j survives 0.6.4/0.6.5/HEAD). STAGED.

The pattern (reusable across languages)

A common "fix" for tar/zip symlink traversal validates the symlink TARGET stays within the extraction dir by resolving it against the lexical (unresolved) parent of the entry:

resolved = outputPath.getParent().resolve(linkTarget).normalize()   // Java: pure string op
if (!resolved.startsWith(target)) reject

This blocks the SINGLE-HOP case (a symlink pointing directly outside) but is BLIND to on-disk symlinks created EARLIER in the same extraction. A multi-hop chain defeats it: 1. dir a 2. symlink a/b -> .. (lexical (target/a)/.. = target -> ALLOWED; on disk a/b -> target) 3. symlink a/b/c -> .. (lexical (target/a/b)/.. = target/a -> ALLOWED, treats b as a dir; but a/b REALLY resolves to target, so the link is created at target/c -> outside target) 4. file c/pwned (lexical target/c/pwned within target -> ALLOWED; the write follows the c symlink parent -> writes OUTSIDE target) Every guard returns SAFE; the write escapes. The bug: the check treats each path component as a real directory (lexical depth) instead of resolving on-disk symlinks (real depth). A parent symlink that points "up" makes real-depth < lexical-depth, so a later .. that looks safe lexically actually escapes.

Detection heuristic (add to the extraction-audit checklist)

When auditing a tar/zip symlink fix, ask: does the containment check use LEXICAL ops (normalize(), filepath.Clean, string startsWith) or REAL-PATH ops (toRealPath(), filepath.EvalSymlinks, os.Root/openat2)? - Lexical only -> almost always bypassable by a multi-hop chain (this lesson). Residual = HIGH. - Final-component NOFOLLOW only (Java LinkOption.NOFOLLOW_LINKS, O_NOFOLLOW) -> protects the leaf, NOT parent components -> still chain-bypassable. Residual = HIGH. - Real-path per-op (toRealPath of the parent re-checked each entry) or os.Root/openat2 per-component (oras-go, qbee-transport) -> sound. WALK. The tell: getParent().resolve(link).normalize() / filepath.Join+Clean with NO toRealPath/ EvalSymlinks/os.Root anywhere = lexical = chain-bypassable.

Empirical-proof technique (banked)

On a JRE-only box (no javac/maven) you can still run a faithful PoC: copy the VERBATIM guard methods from source into a single-file program + drive the real FS APIs, and launch with Java's single-file source mode java Foo.java (JEP 330, no compiler step needed). This exercises the real guard logic + real Files.createSymbolicLink/Files.newOutputStream(NOFOLLOW) behavior. Prefer this over a shell/python replica when the sink is language-specific.

Cross-refs

Correct fixes: [[oras-go-realpathforwrite-dangling-safe-containment]] (os.Root), qbee-transport-osroot-tar-extraction-complete-fix (os.Root + O_EXCL). Wrong fixes: this (lexical Java), incus/LXD QEMU write (unconfined). Port-lag note: oras-GO fixed it right (os.Root); oras-JAVA (same org, different language) shipped a lexical fix that regressed the guarantee = cross-LANGUAGE port-lag (the Go sibling is the reference remediation).

Generated 2026-08-08 05:45:05 UTC | auto-sync /15min