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)
PoC verification
Evidence pack
findings/poc/DF-4482 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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)
- 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 insidedev_dstrategy()(sys/dev/disk/md/md.cmdstrategy_malloc()ends in an inlinebiodone(bio)), socomplete_rqe() -> freerq()(sys/dev/raid/vinum/vinuminterrupt.c:211-262) frees the request before the drain'ssd->waitlist->nextread. Nothing ever unlinks a request fromsd->waitlistat completion time. - Append UAF β
sys/dev/raid/vinum/vinumrequest.c:327-339(orig): a new conflicting write walkssd->waitlistto append at the tail; the walk dereferences chain nodes that the drain frees concurrently (cross-CPU, no lock). - 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 andfreerq()inside its owndev_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:
launch_requests()takes a launcher reference onrq->activebefore firing, socomplete_rqe()can neverbiodone()+freerq()while the launcher still walks the group chain; the launcher drops the reference under the mplock afterwards and runs the factoredfinish_request()itself when everything completed inline;- the drain atomically steals the waitlist and unlinks each request before launching it (attempt 2's drain, kept);
- 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
fixedv2 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}
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.
No comments yet.