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.
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.
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.
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.
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).