β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-4669

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4669 Β· 10 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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; setup kldload snp as root.
  • Build: cc -O1 -o /tmp/snp_reattach /tmp/snp_reattach.c -lutil (as maxx).
  • Run: /tmp/snp_reattach as 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)

  1. 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.
  2. SNPSTTY #2: the un-poison block if (snp->snp_target == NULL) (snp.c:560) is DEAD CODE on re-attach β€” snp_target/snp_tty are 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 and snp_olddisc is overwritten with B's old discipline (A's becomes unrecoverable).
  3. close(snp fd): snpclose (snp.c:484-508) frees snp_buf, clears SNOOP_OPEN, snp_detach detaches tty B only (snp_tty==B) and frees the snoop struct (snp.c:478-479). Tty A still holds dangling t_sc.
  4. close(tty A): ptsclose β†’ snplclose (snp.c:129) reads freed tp->t_sc β†’ snp_down(freed) (snp.c:514): snp_blen still 0 (set by snpclose:494) != 4096 β†’ kfree(stale snp_buf) β†’ double free caught by chunk_mark_free β†’ panic. (Had it not been caught, snp_detach would also kfree(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

fixed
baseline reproduced→ patch + rebuild →patched clean

Same 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
↓ fix.diffper-fix-DF-4669

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: instead of , link -lutil; SNPSTTY takes the tty's st_rdev (dev_t u32) directly; keep ttyB open so the re-attach actually poisons A while snp_tty points at B.

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.