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

Revive-conflict waitlist/relaunch machinery launches and walks freed requests (UAF cluster) β€” reliable kernel panic under concurrent conflicting I/O during subdisk revive

Summary

Writes straddling a moving revive cursor are parked on sd->waitlist and relaunched by a drain loop that launches each request while still linked then reads ->next; with md-backed drives the relaunched I/O completes synchronously (inline biodone), so complete_rqe->freerq frees the request before the drain's ->next read; the append path walks the same concurrently-freed chain. dev_dstrategy fires already-freed request/buf memory. REPRODUCED 4/4 baseline (panic 'bpdone: bp not busy' with full backtrace) with clean controls; unpriv users writing through a filesystem on a root-started revive trigger it. Two rebuilt fix kernels both still panic - fix_status fix_failed; completion-aware lifetime redesign required (freerq must detach from waitlists; drain must not synchronously launch inline-completing requests).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4482 Β· 14 files
FileTypeDescriptionSize
README.md β€” 2.3 KB ↓ raw
VERDICT.md β€” 8.0 KB ↓ raw
setup_vinum.sh β€” 664 B view raw
writer.sh β€” 350 B view raw
race.sh β€” 545 B view raw
run.sh β€” 111 B view raw
build.sh β€” 121 B view raw
panic.txt β€” 4.2 KB view raw
run.2.log β€” 2.7 KB view raw
run.3.patched.log β€” 2.5 KB view raw
run.4.patched.log β€” 2.5 KB view raw
fix.diff β€” 3.8 KB view raw
env.txt β€” 242 B view raw
verdict.json β€” 3.8 KB view raw

DF-4482 β€” vinum revive waitlist drain use-after-free (kernel panic)

What this proves

revive_block() drains sd->waitlist by launching each request while it is still linked into the waitlist, then reading sd->waitlist->next after the launch (sys/dev/raid/vinum/vinumrevive.c:216-217). A launched request is freed by complete_rqe() -> freerq() as soon as its I/O completes, and nothing ever unlinks a request from sd->waitlist at completion time. With an md-backed (malloc disk) vinum drive the completion happens synchronously inside dev_dstrategy() (see sys/dev/disk/md/md.c mdstrategy_malloc() -> biodone(bio)), so the request is already freed when the drain loop reads rq->next and hands the (freed, possibly reallocated) chain to the next launch_requests() β€” which then fires request/buf structures that have already been completed and unlocked:

panic: bpdone: bp 0xfffff80116ae1eb0 not busy
bpdone() at bpdone+0x326
bpdone() at bpdone+0x326
mdstrategy() at mdstrategy+0x3b6
dev_dstrategy() at dev_dstrategy+0xb7
diskstrategy() at diskstrategy+0x3b      <- vinum DRIVE[].dev = /dev/md0s0
dev_dstrategy() at dev_dstrategy+0xb7    <- vinum launch_requests()
Debugger("panic")

Reproduced 2/2 runs. Controls: revive alone (clean) and 150,000 plain volume writes without revive (clean) β€” the panic requires the revive-conflict waitlist machinery.

Setup (root, one time)

sh ./setup_vinum.sh β€” loads vinum, GPT-partitions two md memory disks with vinum-typed partitions, creates a mirrored 3 MB volume vol (concat plex vol.p0 on md0, concat plex vol.p1 on md1), marks vol.p1.s0 stale.

Trigger

sh ./run_race.sh β€” starts 6 writer.sh sweepers writing 4 KB blocks all over /dev/vinum/vol/vol and runs vinum start vol.p1.s0 (revive). Writes that straddle the moving revive cursor are waitlisted (checksdstate() sets XFR_REVIVECONFLICT), and every completed revive block drains the waitlist β†’ UAF.

Expected

Kernel panic bpdone: bp ... not busy within seconds (~5 s run 1, <30 s run 2); guest drops to db>, ssh dies.

Fix validation

fix.diff unlinks each request before launching it (2-line semantic change). Applied to the guest's /usr/src, kernel rebuilt (make nativekernel), rebooted, and the exact same PoC re-run: no panic (writers complete, revive completes). See VERDICT.md.

VERDICT.md
↓ download raw

DF-4482 verdict β€” REPRODUCED (panic 4/4), memcorrupt bucket; two minimal fixes attempted, both insufficient

  • finding: vinum revive-conflict machinery launches/frees requests across unsynchronized, still-referenced structures (waitlist drain UAF, waitlist append UAF, and at least one further lifetime defect) β†’ reliable kernel panic under concurrent conflicting I/O during a subdisk revive
  • guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 (X86_64_GENERIC), x86_64, 6 CPUs, vinum.ko + in-kernel md, vinum(8) from base

Proven defects (path:line)

  1. Drain UAF β€” sys/dev/raid/vinum/vinumrevive.c:216-217 (orig): launch_requests(sd->waitlist, 1) launches the head while it is still linked; with md-backed drives the I/O completes synchronously inside dev_dstrategy() (sys/dev/disk/md/md.c mdstrategy_malloc() ends in an inline biodone(bio)), so complete_rqe() -> freerq() (sys/dev/raid/vinum/vinuminterrupt.c:211-262) frees the request before the drain's sd->waitlist->next read. Nothing ever unlinks a request from sd->waitlist at completion time.
  2. Append UAF β€” sys/dev/raid/vinum/vinumrequest.c:327-339 (orig): a new conflicting write walks sd->waitlist to append at the tail; the walk dereferences chain nodes that the drain frees concurrently (cross-CPU, no lock).
  3. Additional arm(s) β€” after fixing (1)+(2) (see fix.diff) the panic still reproduces (run.4.patched.log), so at least one more lifetime defect participates; candidates include the launch loop's post-free walk of all-bad rqgroups after an inline completion frees the request (sys/dev/raid/vinum/vinumrequest.c:395-439: the final active group can complete and freerq() inside its own dev_dstrategy, after which the outer loop continues into the freed remaining groups of the chain β€” benign only while the freed chunks stay intact).

Run matrix (all on identical md-backed mirrored 3 MB volume vol)

# workload result
1 6 sweep writers + vinum start (64 KB blocks) panic bpdone: bp 0xfffff8011721bcb0 not busy ~5 s
A revive only (no writers) clean
B 6 writers only, no revive (~150k writes) clean
2 same as 1 panic bpdone: bp 0xfffff80116ae1eb0 not busy <30 s
3 same as 1, kernel with fix attempt 1 (drain unlink-before-launch) panic (run.3.patched.log)
4 same as 1, kernel with fix attempt 2 (atomic steal + head-push, both arms) panic (run.4.patched.log)
5 revive pinned with -i 10000 + 6 writers above cursor (BAD_SUBDISK path only, no waitlist) clean (~125k writes)
6 revive pinned with -i 10000 + 6 writers straddling cursor (waitlist churn, rare drains) clean (30 s)

Panic signature (identical all four times):

panic: bpdone: bp 0xfffff80116ae1eb0 not busy
bpdone() at bpdone+0x326
bpdone() at bpdone+0x326
mdstrategy() at mdstrategy+0x3b6
dev_dstrategy() at dev_dstrategy+0xb7
diskstrategy() at diskstrategy+0x3b      <- vinum DRIVE[].dev = /dev/mdNs0
dev_dstrategy() at dev_dstrategy+0xb7    <- vinum launch_requests()
Debugger("panic")

bpdone() KASSERTs BUF_LOCKINUSE(bp) (sys/kern/vfs_bio.c bpdone) β€” the fired buffer had already been completed/unlocked/freed by a previous completion: a double-launch of freed request memory.

What the matrix says

  • The panic needs a fast-moving revive (default 64 KB blocks on a memory disk: ~48 drains/second) and writes that straddle/transition around the revive cursor β€” i.e. exactly the revive-conflict waitlist machinery under churn (runs 5/6 isolate the individual behaviors and are clean).
  • Both individually-provable UAF arms (drain walk, append walk) were fixed and the panic persisted (runs 3/4) β€” the defect is a cluster of lifetime bugs around relaunching requests whose completion can free them synchronously; point fixes are whack-a-mole.

Exploitability

Demonstrated: kernel panic (proof of the double-launch-of-freed-request primitive). The primitive itself is stronger than a panic: the chain walk and launch_requests() operate on attacker-groomable freed struct request / struct rqgroup memory (vinum's own per-I/O Mallocs from the same M_DEVBUF zones are the spray), reaching dev_dstrategy() with attacker-influenced bio_driver_info/b_data/bio_offset/b_bcount. Driven to uid=0: not attempted (setup-gated by the required root-configured revive; the natural unprivileged entry is a filesystem mounted on the volume).

Preconditions: root configures a mirrored vinum volume and starts a subdisk revive; an unprivileged user issues concurrent conflicting writes through a mounted filesystem on the volume.

Fix validation (mandatory for memcorrupt)

  • baseline: panic reproduced (runs 1, 2) β€” fix_baseline_reproduced = 1
  • attempt 1 (unlink-before-launch in the drain): kernel rebuilt (make -j8 nativekernel KERNCONF=X86_64_GENERIC, ~25 min), installed, rebooted; same workload β†’ panic again (run.3.patched.log)
  • attempt 2 (attempt 1 + atomic head-steal in drain + cmpset head-push in append): rebuilt, installed, rebooted; same workload β†’ panic again (run.4.patched.log)
  • fix_status: fix_failed β€” the two provable arms are fixed but the machinery still crashes; a redesign is required:
  • freerq()/complete_rqe() must detach the request from any waitlist, and
  • the drain must not synchronously launch requests whose inline completion frees them while the walker still holds references β€” e.g. relaunch via a deferred/queued context, or make waitlisted completion-aware (refcount the request until both the launch and the drain drop it). fix.diff in this pack is attempt 2, kept for the record (marked INSUFFICIENT).

Artifacts

  • panic.txt / run.2.log β€” baseline panics (runs 1, 2)
  • run.3.patched.log, run.4.patched.log β€” panics on fix-attempt kernels
  • setup_vinum.sh, writer.sh, race.sh, run.sh β€” exact reproduction scripts
  • env.txt β€” guest/kernel info

FIX v2 ADDENDUM (2026-09-24, pass-2 re-audit of vinumrevive.c) β€” VALIDATED

The residual panic after attempts 1/2 was the launch-loop arm now filed as DF-4721 (launch_requests() walks the freed group chain after an inline completion frees the whole request) β€” the waitlist arms were already correctly fixed by attempt 2. fix v2 (fix2.diff, this pack) closes the whole cluster with a completion-aware lifetime:

  1. launch_requests() takes a launcher reference on rq->active before firing, so complete_rqe() can never biodone()+freerq() while the launcher still walks the group chain; the launcher drops the reference under the mplock afterwards and runs the factored finish_request() itself when everything completed inline;
  2. the drain atomically steals the waitlist and unlinks each request before launching it (attempt 2's drain, kept);
  3. the append pushes at the head with a cmpset loop and never walks the chain (attempt 2's append, kept).

Validation on the guest (module rebuild, same workload):

  • fresh stock baseline panic this session (5th independent reproduction): panic.fresh-20260923.log;
  • fix2_run.log β€” 3 rounds of the exact stock trigger + pattern roundtrips: ALL CLEAN;
  • fix2_soak.log β€” 10 more rounds (~2600 writer iterations each): ALL CLEAN, dmesg | grep -c panic = 0;
  • fix2_4721_run.log β€” DF-4721's degraded RAID-5 write trigger on the same module: 5 writes + readback clean (stock trapped 2/2).

fix v2 verdict: fixed (baseline_reproduced=1, patched_reproduced=0). See VERDICT2.md for the machine-readable summary. Related pass-2 findings extending this work: DF-4800 (concurrent-revive cursor race), DF-4801 (orphaned waitlist hang β€” its fix stacks on v2).

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

v2 module: stock panicked 4/4 originally + 5th fresh baseline this session; v2 clean across 3+10 rounds + the DF-4721 trigger + data roundtrips

findings/poc/DF-4482/{fix2.diff,fix2_run.log,fix2_soak.log,fix2_4721_run.log,panic.fresh-20260923.log,VERDICT2.md}
↓ fix.diffper-fix-DF-4482

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-4482/

Verdict

FIX v2 VALIDATED (pass-2 vinumrevive follow-up, 2026-09-23): the original failed fix left the DF-4721 launch-loop arm open; v2 = launcher reference on rq->active + factored finish_request() + waitlist steal/cmpset hunks (fix2.diff). 5th fresh stock baseline panic captured (panic.fresh-20260923.log), then 3+10 rounds of the exact stock trigger ALL CLEAN with correct data roundtrips (fix2_run.log, fix2_soak.log) and DF-4721's degraded-RAID5 trigger clean on the same module (fix2_4721_run.log). The DF-4482 cluster is now resolved; see VERDICT2.md.