DF-2972 / verdict.json
{ "finding_id": "DF-2972", "status": "reproduced", "reproduced": 1, "impact": "leak", "confidence": "certain", "verdict": "exec_shell_imgact() scans the script's live page-cache first page twice (accounting at sys/kern/imgact_shell.c:75-96, copy at :138-162). A MAP_SHARED writable mapping that outlives close() can mutate the interpreter line between the scans -- exec_check_permissions' ETXTBSY (kern_exec.c:1325) only counts open-for-write fds (vfs_default.c:1213-1216 drops v_writecount on close while the mapping persists). Reproduced as an unprivileged user with ~100% divergence rate while racing (tmpfs and hammer2): the kernel reserved space for one interpreter line and copied another, corrupting fname/argv placement inside the 266,240-byte args->buf object (no OOB write possible: scan-2 writes bounded by the page to buf[0..4094]; interpreter_name stays MAXSHELLCMDLEN-bounded) and exposing stale prior-exec content of the recycled exec-args objcache object as interpreter argv strings (kernel-heap bytes inside the copyout block at kern_exec.c:1220 handed out by the argv walk at kern_exec.c:1231-1236). No panic in any run; guest stayed up. Realistic ceiling: unprivileged cross-process snooping of other users' exec argv/env via a raced-exec loop; cross-user snooping itself not demonstrated on the single-user guest.", "exploit_chain": "unpriv user: (1) create #!/bin/echo script; (2) open O_RDWR, mmap MAP_SHARED RW, close fd (ETXTBSY disarmed); (3) thread flips interpreter-line bytes while exec'ing the script with ~200KB env to widen the scan1->scan2 bcopy window; (4) scan1!=scan2 => fname/argv overlap or stale gap inside the copyout block; (5) interpreter prints argv incl. stale recycled-object strings (prior execs' argv/env) -- loop to snoop. No escalation: not memory corruption (all writes in-object).", "evidence": [ "run.log: DF2972_HIT iter=3 out=[... /tmp/df2972/t ERARG] (overlap destroys USERARG prefix)", "run.log: DF2972_HIT iter=1 out=[... EEEUSERARG] (stale gap merges strings)", "run.log: DF2972_HIT iter=2 out=[... /tmp/df2972/t 00=EEEE...] (stale prior-exec env string K00=EEE... from recycled exec-args object delivered as argv)", "run.hammer2.log: same on hammer2 root FS (not tmpfs-specific)", "VERDICT.md: full mechanism with path:line and bounds proof", "leak_sample.txt: stale-byte samples across 4 runs" ], "kernel_refs": [ "sys/kern/imgact_shell.c:75", "sys/kern/imgact_shell.c:120", "sys/kern/imgact_shell.c:123", "sys/kern/imgact_shell.c:126", "sys/kern/imgact_shell.c:138", "sys/kern/imgact_shell.c:160", "sys/kern/imgact_shell.c:169", "sys/kern/imgact_shell.c:173", "sys/kern/kern_exec.c:770", "sys/kern/kern_exec.c:1220", "sys/kern/kern_exec.c:1231", "sys/kern/kern_exec.c:1325", "sys/kern/kern_exec.c:137", "sys/kern/vfs_default.c:1213", "sys/kern/kern_descrip.c:3328" ], "poc_changes": "written fresh this pass (no prior seed): race_demo.c -- close-after-mmap bypasses ETXTBSY; mutator flips interpreter-arg bytes 12..59 between 1-token/49-byte and 1-token/5-byte states; ~200KB env widens the scan1->scan2 bcopy window; divergence-strict detector (fname/USERARG adjacency) avoids false positives from mid-flip single-state views; plus a hammer2-root-FS variant run.", "attempts": 5, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "runtime_sec": 95, "guest_dirty": 0, "build_cmd": "cc -O2 -pthread -Wall -o race_demo race_demo.c", "run_cmd": "./race_demo 40000 8", "code_hash": "126aa8029d30f39bfc8baee9a092a142c6f2ffebcc99467946f0f7e617c44d8c", "notes": "5 positive runs (8/6/4/4 hits + hammer2 4/4), zero consistent/mixed outputs while racing (near-deterministic divergence at full flip rate); no panic ever -- matches in-object bounds proof. DF-0243 (known, not re-reported) re-exercised this pass at argv[0]=256/4096/65536/262140: no panic, script ran normally, confirming the existing false-positive verdict (two's-complement wrap is equivalent to the intended signed adjustment; space truncates back to the correct int).", "recommended_fix": "Snapshot the mapped first page once (kmalloc+bcopy) after the interpreted check and run both the accounting and copy passes against the snapshot; see fix.diff.", "fix_status": "not_testable", "fix_kernel_uname": "", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "fix.diff (page snapshot, behavior-preserving for stable content) authored after verification; apply-checked against guest /usr/src with 'patch --dry-run -p1' -- all 6 hunks clean (APPLY_CHECK_OK). Kernel rebuild and patched-behavior rerun not performed: non-corruption-class finding, fix validation not mandated for it; snapshot approach trivially removes the second read of the mutable page.", "fix_evidence": [ "fix.diff", "VERDICT.md section 'Fix' (apply-check result)" ] } |