snp: SNPSTTY re-attach cleanup is dead code β old target tty left poisoned with dangling t_sc β deterministic snplclose UAF / slab double free
Summary
The un-poison block is guarded by snp_target==NULL which is never true on re-attach (target and tty are only set/cleared together), so the old tty keeps t_sc/TS_SNOOP/t_line poisoned and snp_olddisc is overwritten unrecoverably. Closing the snp fd frees the struct; closing the old tty then runs snp_down(freed) -> kfree of the already-freed 4096-byte snp_buf read from freed memory, and snp_detach would double-kfree the struct. No race needed - sequential ioctls+closes, 100% deterministic; DFly slab zones are per-size-class so cross-type aliasing is available. Wheel/root-gated (0600 device). REPRODUCED on guest ('memory chunk already free' panic with full stack) and FIX-VALIDATED. Real un-poison fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4669 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| snp_reattach.c | β | 2.1 KB | view raw | |
| build.sh | β | 109 B | view raw | |
| run.sh | β | 131 B | view raw | |
| run.log | β | 2.1 KB | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| env.txt | β | 413 B | view raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| fix.diff | β | 1.5 KB | view raw | |
| manifest.json | β | 791 B | view raw | |
| verdict.json | β | 3.7 KB | view raw |
DF-4669 β VERDICT
Status: reproduced. Impact: panic via slab-detected double free (deterministic UAF, no race). Confidence: certain.
What was run
- Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (QEMU/KVM),
fresh
vm.sh reset with-src; setupkldload snpas root. - Build:
cc -O1 -o /tmp/snp_reattach /tmp/snp_reattach.c -lutil(as maxx). - Run:
/tmp/snp_reattachas root (SNPSTTY needs /dev/snpN, 0600 root:wheel). Output up to the crash was lost with the ssh connection; the serial console captured the panic.
Decisive evidence (panic.txt)
panic: memory chunk 0xfffff80116d96000 is already free!
cpuid = 3
chunk_mark_free() at chunk_mark_free+0xae
_kfree() at _kfree+0x262
snp_down() at snp_down+0xa0
snplclose() at snplclose+0x2b
ptsclose() at ptsclose+0x71
Debugger("panic")
snp_down+0xa0 is kfree(snp->snp_buf, M_SNP) at snp.c:518 β executed with
snp = the struct snoop freed by snpclose, whose stale snp_buf (offset 56)
still points at the 4096-byte buffer snpclose already freed at snp.c:496.
Why it reproduces (path:line trace)
- SNPSTTY #1 (snp.c:540-580): tty A gets
t_sc=snp,TS_SNOOP,t_line=snooplinedisc;snp_target=snp_tty=A;snp_olddisc=A's old disc. - SNPSTTY #2: the un-poison block
if (snp->snp_target == NULL)(snp.c:560) is DEAD CODE on re-attach βsnp_target/snp_ttyare only ever set/cleared together (snp.c:434-435, 473-474, 570-571) β so tty A keeps all three poison values while the snp switches to tty B andsnp_olddiscis overwritten with B's old discipline (A's becomes unrecoverable). - close(snp fd):
snpclose(snp.c:484-508) freessnp_buf, clears SNOOP_OPEN,snp_detachdetaches tty B only (snp_tty==B) and frees the snoop struct (snp.c:478-479). Tty A still holds danglingt_sc. - close(tty A):
ptscloseβsnplclose(snp.c:129) reads freedtp->t_scβsnp_down(freed)(snp.c:514):snp_blenstill 0 (set by snpclose:494) != 4096 βkfree(stale snp_buf)β double free caught bychunk_mark_freeβ panic. (Had it not been caught,snp_detachwould alsokfree(snp)a second time β snp.c:478-479 with SNOOP_OPEN already clear.)
No race required β purely sequential ioctls + closes.
Primitive characterization (per contract: push past the panic)
The primitive is kfree(*(void **)(freed_chunk + 56)) β an attacker-influenced
free of a pointer read from freed M_SNP memory β plus a second free of the
freed 80-byte struct itself. DFly's slab allocator selects zones by SIZE CLASS
ONLY (zoneindex(&size) / slgd->ZoneAry[zi], sys/kern/kern_slaballoc.c:967-971);
the malloc type only does accounting. So a reallocated-in-between 4096 chunk
becomes a live object of ANY subsystem in that size class, and the second
snp_down kfree frees a LIVE chunk β cross-type aliasing (classic double-free
β two owners β type confusion). Reclaim of the 80-byte struct is equally
cross-type (any 96-class allocation).
Why uid=0 was not chained end-to-end here: the trigger chain requires
opening /dev/snp (0600 root:wheel) and issuing SNPSTTY β an unprivileged
user cannot reach the primitive at all (device permissions, snp.c:705/723),
so this is not an unprivβroot bug; it is a privileged-userβkernel-heap
corruption with cross-type reclaim potential. Given the wheel-gated trigger,
the escalation value is limited, and the deterministic double-free panic (plus
the reclaim analysis above) is the honest impact ceiling: kernel memory
corruption reachable from wheel.
Fix validation
fix.diff replaces the dead guard with a real un-poison (restore old tty's
t_sc/TS_SNOOP/t_line under tpo->t_token before re-attaching). Applied
to the guest /usr/src, kernel rebuilt (make nativekernel), both PoCs re-run β
see verdict.json fix_* fields and run.2.log.
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
fixedSame fix.diff (both hunks) applied, kernel rebuilt and installed; /tmp/snp_reattach as root on patched kernel: both SNPSTTYs succeed, snp fd close succeeds, close(ttyA) succeeds, exit=0, guest up, console clean. Baseline double-free panic in panic.txt.
run.2.log (patched: 'no panic observed', exit=0); panic.txt (baseline slab double-free); fix.diff; build.log
Confirmed kernel references
Detail
Exploit chain
wheel-gated: open /dev/snpN -> SNPSTTY(ttyA) -> SNPSTTY(ttyB) [A poisoned] -> close(snp fd) [struct snoop + snp_buf freed, A dangling] -> close(ttyA) -> snplclose -> snp_down(freed): kfree(*(freed+56)) double-free of the 4096-byte chunk + (if reached) second kfree of the 80-byte struct. DFly slab zones are selected by size class only (kern_slaballoc.c:967 zoneindex/ZoneAry), NOT by malloc type, so reallocating the freed chunks in between turns this into a free-of-live-chunk with cross-type aliasing (two owners of one chunk). Not reachable from a non-wheel user: /dev/snp is 0600 root:wheel, so unpriv->root is out; honest ceiling is privileged-user->kernel heap corruption / reliable panic.
Evidence (decisive lines)
panic.txt: 'panic: memory chunk 0xfffff80116d96000 is already free!' + stack 'chunk_mark_free <- _kfree <- snp_down+0xa0 <- snplclose+0x2b <- ptsclose+0x71' (= kfree at snp.c:518 operating on the freed struct's stale snp_buf). run.2.log: patched kernel survives the identical sequence.
PoC changes
Seed sketch fixed:
Verified recommended fix
SNPSTTY: replace the dead 'if (snp->snp_target == NULL)' block with a real un-poison of snp->snp_tty under its token (clear t_sc/TS_SNOOP, restore t_line from snp_olddisc) before attaching to the new tty.
Verdict
Deterministic (no-race) use-after-free / double free in the M_SNP heap reproduced as root/wheel: SNPSTTY's re-attach cleanup is dead code (guard snp_target==NULL is never true on re-attach because snp_target/snp_tty are only set/cleared together), so the old target tty keeps t_sc=snp + TS_SNOOP + t_line=snooplinedisc; closing the snp fd frees struct snoop; closing the old tty runs snplclose -> snp_down(freed snp) -> kfree(stale snp_buf). Slab allocator catches it: 'panic: memory chunk ... is already free!' with stack chunk_mark_free <- _kfree <- snp_down <- snplclose <- ptsclose. Patched kernel: full poison chain runs clean, exit=0, no panic.
No comments yet.