cd9660_lookup evaluates shared dp->i_ino after vn_unlock in the DOTDOT path: concurrent lookup race can bind '..' to an arbitrary concurrently-scanned inode (wrong parent vnode) or spuriously fail
Summary
In the CNP_ISDOTDOT branch, the directory vnode is unlocked (vn_unlock(pdp), :343) and only afterwards are the arguments cd9660_vget_internal(vdp->v_mount, dp->i_ino, &tdp, dp->i_ino != ino, ep) evaluated (:344-345). dp->i_ino is a field of the shared iso_node that every concurrent cd9660_lookup on the same directory mutates continuously during its scan (:260 dp->i_ino = ino; :261 rrip_getname may overwrite via CL/PL inump; :273). A thread that wins the lock in the unlock-to-argument-evaluation window can swap dp->i_ino under the first thread, so '..' resolves via vget to an arbitrary entry's inode - a wrong-but-valid vnode (possibly a VREG returned as the parent directory, on which namei will run directory lookups), or a CL-garbage ino that merely errors. No memory corruption (vget_internal's relocated path bounds-checked; out-of-device inos fail EIO). Identical idiom in ufs_lookup.c:479-480/526-527 - fix should be coordinated. Trigger: two unpriv threads looping lstat('..') and stat('<other-entry>') in the same directory on any mounted ISO (RRIP images best groom). VFS-logic integrity issue, not privesc. Phase V skipped per contract (Low, instruction-scale window); certain by inspection. Fix: snapshot dp->i_ino/reloc into locals before vn_unlock. [VERIFIED pass2: 32.2M lstat dotdot iterations zero misresolutions - lock-handoff latency beats instruction-scale window; structurally real; fix compile-validated]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3206 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_dotdot_iso.py | β | 3.4 KB | view raw | |
| racer.c | β | 2.8 KB | view raw | |
| run.log | β | 426 B | view raw | |
| README.md | β | 636 B | β raw | |
| VERDICT.md | β | 2.1 KB | β raw | |
| build.sh | β | 183 B | view raw | |
| run.sh | β | 157 B | view raw | |
| manifest.json | β | 730 B | view raw | |
| verdict.json | β | 1.8 KB | view raw | |
| fix.diff | β | 657 B | view raw | |
| env.txt | β | 272 B | view raw |
DF-3206 -- verification pack
Finding: cd9660_lookup evaluates shared dp->i_ino after vn_unlock in the DOTDOT path
Status: not_reproduced / impact denial_of_service / confidence likely
What was run
host: python3 craft_dotdot_iso.py out.iso; guest: cc -O -o racer racer.c -lpthread
How to reproduce
guest: mount_cd9660 -o ro /dev/vn2 /mnt && su -m nobody -c /tmp/racer /mnt 90
Expected
expected occasional HIT lines (ino != root); observed zero in 32.2M iterations
See VERDICT.md for the full narrative and verdict.json for the persisted machine verdict. Artifacts: craft_dotdot_iso.py, racer.c, run.log.
DF-3206 verification
Kernel: DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64
Bottom line
32.2 million lstat(DIR/..) iterations against a crafted RRIP ISO (64 sibling entries in the raced directory) with two concurrent scanners produced zero misresolutions in 90 seconds. The defect is structurally real - dp->i_ino is shared scan scratch, cd9660_lookup unlocks the directory at :343 and only then evaluates dp->i_ino as the vget argument at :344-345 - but the window between the unlock return and the i_ino load is a few instructions, and a blocked scanner wakes from the vnode lock far too late (sleep-based handoff) to overwrite i_ino inside it on this guest. The crafted ISO and racer are retained; the fix (snapshot i_ino/reloc into locals before the unlock) is compile-validated.
Kernel references
Exploit chain
(none: availability/correctness finding; no privilege chain)
Fix
snapshot dp->i_ino and the reloc flag into locals before vn_unlock
See fix.diff in this directory (git-apply-able against the read-only sys/ tree; applied only inside the disposable verification guest).
Reproduction
build: host: python3 craft_dotdot_iso.py out.iso; guest: cc -O -o racer racer.c -lpthread
run: guest: mount_cd9660 -o ro /dev/vn2 /mnt && su -m nobody -c /tmp/racer /mnt 90
expected: expected occasional HIT lines (ino != root); observed zero in 32.2M iterations
Notes on the PoC (what changed vs the finding sketch)
namecache serves .. after first resolution, so hit rate depends on cache churn (vfs.cache clean_neg_count 9M shows aggressive reclaim); 3 racing threads give 2 real scanners
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
run.log: root ino 65536, DIR ino 69632, a_iters=32228978 a_hits=0; racer.c, craft_dotdot_iso.py
PoC changes
namecache serves .. after first resolution, so hit rate depends on cache churn (vfs.cache clean_neg_count 9M shows aggressive reclaim); 3 racing threads give 2 real scanners
Verified recommended fix
snapshot dp->i_ino and the reloc flag into locals before vn_unlock
Verdict
32.2 million lstat(DIR/..) iterations against a crafted RRIP ISO (64 sibling entries in the raced directory) with two concurrent scanners produced zero misresolutions in 90 seconds. The defect is structurally real - dp->i_ino is shared scan scratch, cd9660_lookup unlocks the directory at :343 and only then evaluates dp->i_ino as the vget argument at :344-345 - but the window between the unlock return and the i_ino load is a few instructions, and a blocked scanner wakes from the vnode lock far too late (sleep-based handoff) to overwrite i_ino inside it on this guest. The crafted ISO and racer are retained; the fix (snapshot i_ino/reloc into locals before the unlock) is compile-validated.
No comments yet.