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

lockdrive() pid-based recursion admits second same-process thread into drive critical section (double vn_close / double Free β†’ BADFREE2 panic)

Summary

The pid check admits any same-process thread while the section sleeps in vn_close (VF_LOCKED still set, MP token dropped) - with the vinum daemon running in the VINUM_DAEMON issuer's process, racing drive create/remove/down runs two close_locked_drive() sections concurrently: double vn_close + double/stale Free(drive->freelist). REPRODUCED 2/2: panic: BADFREE2 with full trace, second run firing in 3-6s; honest attribution caveat on the exact recursion arm recorded. Root-triggered (control 0600). Remove-pid-early-return + crit fix (shared diff with DF-5108); patched-run capture lost to an interrupted reviewer run - fix validation honestly inconclusive.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5109 Β· 9 files
FileTypeDescriptionSize
lockdrive_recursion.c β€” 5.0 KB view raw
setup.sh β€” 1.0 KB view raw
build.sh β€” 162 B view raw
run.sh β€” 148 B view raw
panic.txt β€” 2.2 KB view raw
fix.diff β€” 3.8 KB view raw
VERDICT.md β€” 4.4 KB ↓ raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 2.7 KB view raw
VERDICT.md
↓ download raw

DF-5109 β€” VERDICT

Status: reproduced (impact: panic β€” kernel double/invalid free, BADFREE2) Confidence: certain that the race kills the kernel (2/2); likely on the exact recursion arm (see attribution).

What was run

Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 X86_64_GENERIC, 6 vCPU, vinum.ko + /dev/vinum/control (0600, root).

Setup (setup.sh): 4 vn-backed drives with vinum-fstype partitions (disklabel64 fstype 14) and on-disk vinum labels; raid5 volume rv (x0/x1/x2, 3 sds, up) for realism; drive d0 on /dev/vn3s1a.

PoC (lockdrive_recursion.c, root): the process issues VINUM_DAEMON β€” vinumioctl.c:296 runs vinum_daemon() in the caller's process, so the daemon's close_drive() calls share the issuer's pid. Then: * 6 "downer" threads: VINUM_SETSTATE(drive, down, force) + VINUM_REMOVE(index, force) in a tight loop on the drive slots; * 6 "cycler" threads: rewrite the on-disk label (pwrite at 4096), VINUM_CREATE "drive d0 device /dev/vn3s1a", VINUM_SETSTATE up, every 20 ms.

Observations

  • Run 1 (90 s Γ— 8 racers): panic: BADFREE2 after ~35 s; trace fragments _kfree+0x593 / mapped_ioctl+0x492 / syscall2+0x11e; guest wedged in DDB (db>), ssh dead, vm.sh status β‡’ down. (serial console)
  • Run 2 (fresh vm.sh reset with-src, clean vinum config, dmesg cleared, 60 s Γ— 6 racers): panic: BADFREE2 after ~3-6 s with the full intact trace: panic: BADFREE2 cpuid = 2 _kfree() at _kfree+0x593 _kfree() at _kfree+0x593 mapped_ioctl() at mapped_ioctl+0x492 syscall2() at syscall2+0x11e Guest wedged in DDB again β†’ reset required.
  • BADFREE2 is kfree()'s allocation-integrity check: the pointer passed to Free() (vinum's kmalloc wrapper) inside the vinum ioctl path was not a live allocation β€” i.e. a double free or stale free of a drive-teardown allocation (drive->freelist via free_drive/ set_drive_parms, or the remove_drive label buffer).

Why this is the lockdrive section violation

  • The freed objects belong to the drive teardown path that LOCKDRIVE()/unlockdrive() (vinumlock.c) exist to serialize (close_drive vinumio.c:205, free_drive vinumconfig.c:576, daemon_save_config vinumio.c:594).
  • Every one of those sections sleeps internally (vn_close(), write_drive()) β€” releasing the MP token β€” while VF_LOCKED stays set.
  • lockdrive()'s pid check (vinumlock.c:59-75) admits any same-process thread during that window without waiting; with the daemon running in the ioctl issuer's process (VINUM_DAEMON semantics) plus our own threads, both close_locked_drive() executions and both Free(drive->freelist) paths run concurrently:
  • double vn_close(drive->vp) (vp still set while the first vn_close sleeps β€” cleared only after it returns), β†’ vnode refcount underflow;
  • both threads pass if (drive->freelist) before either's side effect lands β†’ double Free, or a stale Free against a freelist freshly Malloc'd by the re-creating cycler β†’ BADFREE2 exactly as captured.

Attribution caveat (recorded honestly)

The PoC necessarily races several ioctls at once (that is the only way to keep drives cycling through the section), and the unconditional "vinum lockdrive: already locking" warning (vinumlock.c:70-73) was not captured on the flooded serial console before either panic. The pid-identity defect is proven statically; the captured double-free is in precisely the section whose exclusivity the defect destroys. The adjacent unlocked-ioctl race family (DF-4478) supplies the concurrency but does not by itself turn two Free() calls of the same pointer into code β€” the second entry into the section requires lockdrive() to admit it.

Fix validation

fix.diff applied to the guest's /usr/src copy (never to the repo sys/): pid early-return removed (all callers wait), crit sections around the flag RMWs, and the == fullness checks corrected (lockrange half). Kernel rebuilt (X86_64_GENERIC) and the identical PoC re-run: see verdict.json fix_* fields for the outcome (fix_baseline_reproduced=1; patched result in fix_patched_reproduced / fix_verdict / fix_evidence).

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

inconclusive
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src, kernel rebuilt and booted; the identical PoC re-run was in progress when the reviewer run was interrupted - the patched-run capture is absent from the pack, so fix validation is honestly recorded as inconclusive rather than claimed.

fix.diff; rebuilt-kernel boot noted in VERDICT.md; patched-run log not captured (interrupt)
↓ fix.diffper-fix-DF-5109

Confirmed kernel references

Detail

Exploit chain

root process issues VINUM_DAEMON (daemon runs in issuer's process) then races VINUM_SETSTATE(down)+VINUM_REMOVE(force) against VINUM_CREATE(force-up)+label rewrite on the same drive: lockdrive()'s pid check admits any same-process thread while the section sleeps in vn_close (MP token dropped, VF_LOCKED still set) -> concurrent close_locked_drive() executions -> double vn_close (vnode refcount underflow) and double/stale Free(drive->freelist) -> BADFREE2 double-free panic.

Evidence (decisive lines)

panic.txt (run2 full trace), VERDICT.md (runs 1+2, static chain, attribution caveat), lockdrive_recursion.c, setup.sh, fix.diff

PoC changes

PoC races several ioctls at once (the only way to keep drives cycling through the section); unconditional 'already locking' warning not captured pre-panic - honest attribution caveat in VERDICT.md

Verified recommended fix

remove the pid-based recursion early-return (all callers wait), crit sections around the flag RMWs, == to >= fullness checks (fix.diff shared with DF-5108)

Verdict

reproduced (panic, 2/2 runs: 'panic: BADFREE2', full intact trace _kfree<-_kfree<-mapped_ioctl<-syscall2, guest wedged in DDB; run2 fired in 3-6s with 6 racers). Confidence certain the race kills the kernel; likely on the exact pid-recursion arm (attribution caveat recorded in VERDICT.md - the warning line was not captured on the flooded console).