fdformat() 20 s timeout double-completes its bio and recycles the pbuf under the fdc driver -> double-linked bio in fdc->bio_queue -> kernel panic (queue corruption / UAF)
Summary
fdformat submits its format bio via dev_dstrategy - from then on the bio is owned by the single-controller fdc state machine and sits linked in fdc->bio_queue. If not completed within 20s - trivially arrangeable by keeping the shared queue deep with concurrent reads, no hardware fault needed - biowait_timeout returns EWOULDBLOCK and fdformat performs the driver's completion itself: biodone + relpbuf, leaving the freed bio linked and the pbuf free for reuse. The next getpbuf recycles it; the same bio is re-inserted while the stale entry is still linked; the next dequeue does a second TAILQ_REMOVE and panics ('Bad link elm ... next->prev != elm' in fdstate via fdc_intr). REPRODUCED deterministically 2/2 on the QEMU ISA floppy (temp launcher + fdc-enabled kernel rebuild; legal format params nsecs=18, distinct from the FD_FORM knowns). Plain-biowait fix built in-guest and validated: formats complete after the real drain, no panic. SMP race hypothesis refuted at source level (mplock serialization in all contexts) + 4-minute 32-thread stress healthy.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4063 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fd_timeout_uaf.c | β | 4.1 KB | view raw | |
| build.sh | β | 110 B | view raw | |
| run.sh | β | 169 B | view raw | |
| run.log | β | 1.9 KB | view raw | |
| panic.txt | β | 714 B | view raw | |
| run.patched_console.log | β | 90.1 KB | view raw | |
| env.txt | β | 383 B | view raw | |
| fix.diff | β | 1.2 KB | view raw | |
| VERDICT.md | β | 6.4 KB | β raw | |
| README.md | β | 1.6 KB | β raw | |
| verdict.json | β | 3.8 KB | view raw | |
| build.log | β | 604 B | view raw |
DF-4063 β fdformat() timeout β double biodone + pbuf recycle under the driver
sys/dev/disk/fd/fd.c fdformat(): a 20 s biowait_timeout on the format bio
is treated as completion β fdformat() calls biodone() on a bio the fdc
state machine still owns and relpbuf()s the pbuf under it. With the shared
controller queue stalled >20 s by concurrent I/O (no hardware fault needed),
the freed pbuf is recycled by the next getpbuf() (e.g. a second FD_FORMAT),
the same bio gets TAILQ-linked twice in fdc->bio_queue, and the next dequeue
panics the kernel: panic: Bad link elm ... next->prev != elm in fdstate().
Reachable by root (FD_FORM needs a write open on /dev/fd*, 0640 root:operator;
typed nodes 0600 root:wheel). Reproduced deterministically (2/2 panics) on the
audit QEMU guest with an ISA floppy attached; fix (plain biowait) built and
validated in-guest β no early return, no panic.
Reproduce
- Guest kernel must contain
device fdc0 at isa? port IO_FD1 irq 6 drq 2+device fd0(stock GENERIC omits it; the evidence pack's env.txt records the config lines used) and QEMU must attach the fdc:-device floppy,drive=fd0 -drive id=fd0,if=none,file=floppy.img,format=raw(temp launcherdfbsd-qemu/run_fdtest.sh, vm.sh untouched). ./build.shβ cc -O2 -pthread -o /tmp/fd_timeout_uaf fd_timeout_uaf.c- As root:
/tmp/fd_timeout_uaf 128 - Expected (unpatched): fdform#1 β EIO at ~20 s while 128 readers keep the
queue deep; kernel panics β1 min later with
Bad link elm(fdstate). Expected (patched): both FD_FORMs return 0 after the real drain (~37 s), no panic.
DF-4063 β fdformat() 20 s timeout double-completes its bio and recycles the pbuf under the driver (kernel queue corruption / UAF)
Verdict: REPRODUCED (deterministic 2/2) β kernel panic via TAILQ corruption; fix validated on the guest.
Root cause (path:line)
fdformat()(sys/dev/disk/fd/fd.c:2244) submits a BUF_CMD_FORMAT bio throughdev_dstrategy()(fd.c:2276). The bio is now owned by the fdc state machine β it sits infdc->bio_queue(linked bybioq_insert_tail()viafdstrategy()'sbioqdisksort()default case, fd.c:1530) until the controller works its way to it.fdformat()waits withbiowait_timeout(&bp->b_bio1, "fdform", 20 * hz)(fd.c:2279). On timeout it does the driver's job itself:biodone(&bp->b_bio1)(fd.c:2286) and thenrelpbuf(bp, NULL)(fd.c:2293) β while the fdc still holds the bio in its queue and the pbuf is still referenced byfdc->bio.- 20 s is not an upper bound on how long the bio can legally sit in the queue: the single controller is shared by every bio for the drive, so a deep queue (e.g. concurrent reads) delays the format bio arbitrarily long. The timeout is therefore reachable by ordinary queuing, not by hardware failure.
What happens after the premature biodone/relpbuf
biodone()runsbio_done(biodone_sync) and clears it; the bio keeps its stale links insidefdc->bio_queue(fdc->bio == &bp->b_bio1).relpbuf()returns the pbuf to the objcache. The very nextgetpbuf()β e.g. a second concurrent FD_FORMAT ioctl β typically recycles the same pbuf, so the stalefdc->biopointer now aliases the live bio of the new ioctl.- The new
dev_dstrategy()re-inserts&bp->b_bio1intofdc->bio_queuewhile the stale entry (same address!) is still linked β the TAILQ element is linked twice. - When the state machine dequeues (
fdstate(), fd.c:1668-1672bioq_remove()), the secondTAILQ_REMOVEexplodes:panic: Bad link elm ... next->prev != elmβ and the interim completion runsbiodone()/devstat_end_transaction_buf()on a pbuf that no longer belongs to this transfer (kernel UAF;tsleep error biowait 35observed on the collateral waiter).
The primitive is write-capable inside the kernel: the recycled pbuf's
b_data/b_bcount are attacker-influenced (they are the new ioctl's values),
the FDC then DMA-reads b_bcount bytes from b_data+fd->skip (fd.c:1844-1846)
and the final biodone() corrupts whichever object owns the recycled buffer.
Since FD_FORM requires a write open on the fd device (0640 root:operator),
the attacker is already root β no uid0 chain is meaningful; the demonstrated
and shipped impact is the deterministic kernel panic (memory corruption).
Reproduction (guest)
QEMU/KVM 6 vCPU, DragonFly 6.5-DEVELOPMENT, kernel rebuilt with
device fdc0/fd0/fd1, QEMU -device floppy,drive=fd0 -drive
id=fd0,if=none,file=floppy.img,format=raw (temp launcher
dfbsd-qemu/run_fdtest.sh; vm.sh untouched).
fd_timeout_uaf.c: 128 reader threads pread-loop /dev/fd0 (keeps the shared
queue deep); main thread issues FD_FORM (nsecs=18 β legal, not the known
DF-1364/2456 overread) β returns EIO at 20.010 s / 20.014 s while the
queue is still deep; immediately issues FD_FORM #2 which recycles the pbuf;
kernel panics β1 min later in fdstate():
panic: Bad link elm 0xfffff8004f41fcc0 next->prev != elm fdstate() at fdstate+0xdfb fdstate() at fdstate+0xdfb fdc_intr() at fdc_intr+0x18 ithread_handler() at ithread_handler+0x2fe
2/2 runs, identical signature. Full logs: run.log, panic.txt.
Fix validation
fix.diff replaces the timeout+manual-biodone with a plain
biowait(&bp->b_bio1, "fdform"). Rationale: the state machine always
completes every bio it dequeues β progress is bounded per transfer by
fd_iotimeout (1 s) + retrier() (β€8 retries) and parked states are kicked
every 4 s by fd_turnoff (fd.c:1712) β so waiting is deadlock-free and the
driver's ownership of the bio is respected.
Patched kernel (built in-guest, #1 Thu Sep 17 20:51:04 UTC 2026), identical
PoC, identical 128 readers:
- fdform#1:
rc=0 in 37.374 s(waits for the real queue drain β no 20 s early return, no premature biodone) - fdform#2:
rc=0 in 38.008 s - program completes ("done"), guest stays up, no panic (
run.patched_console.log).
Baseline vs patched: unpatched = EIO@20s + guaranteed panic; patched = correct formats + healthy kernel. Fix status: fixed.
Known findings not re-reported (pass 2)
DF-1364/DF-2456 (FD_FORM nsecs OOB), DF-1365/DF-2457 (detach callout UAFs), DF-2455 (FD_STYPE div0/shift). This bug uses nsecs=18 (legal) and is a lifetime bug in fdformat()'s timeout path, not an overread.
Investigated and killed in this pass (negative results)
- fdc results walk
fd_read_status()(fd.c:489-514): loop hard-bounded to 7,fdc->statusisu_int status[7](fdc.h) β no device-fed overflow. - FD_GSTAT/FD_GTYPE/FD_SOPTS: exact-size copies of kernel-data structs; no uninit leak (FDC_STAT_VALID gates), no privilege issue (options benign).
fdstate()SMP safety: the fd callouts use plaincallout_init()(not CALLOUT_MPSAFE) sosoftclock_handler()executes them with the mplock held (sys/kern/kern_timeout.c:575-584); fd_ops lacks D_MPSAFE sodev_dstrategy()/ioctls hold the mplock (sys/kern/kern_device.c:355-383); fdc's interrupt registers non-INTR_MPSAFE β ithread holds the mplock (sys/kern/kern_intr.c:344, confirmed live: dmesginterrupt uses mplock: fdc0). The state machine is therefore serialized in all contexts β a suspected callout-vs-ioctl race was REFUTED at source level (a 32-thread 4-minute stress run on the live fdc also stayed healthy).- DMA page arithmetic:
isa_dmastart(sys/bus/isa/x86_64/isa_dma.c:211) panics via bounce-buffer size check only for fdblk > 1024, which requires FD_STYPE (root) β covered by the known DF-2455 class. - Geometry swaps mid-transfer (FD_STYPE / typed-node open while active): all gated on root (typed nodes are 0600 root:wheel, FD_STYPE is SYSCAP-checked).
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
fixedPatched kernel (in-guest rebuild, #1 20:51:04): identical PoC, fdform#1 rc=0 after 37.4 s real queue drain (no 20 s early return, no premature biodone/relpbuf), fdform#2 rc=0 after 38.0 s, program completed, guest healthy, no panic. Baseline unpatched: 2/2 panics.
findings/poc/DF-4063/run.log (patched section), run.patched_console.log, fix.diff, guest /usr/src/sys/dev/disk/fd/fd.c:2292 grep output
Confirmed kernel references
Detail
Exploit chain
root: 128 reader threads pread-loop /dev/fd0 (deep queue) -> FD_FORM#1 returns EIO at 20.0 s while its bio is still in fdc->bio_queue and the pbuf has been freed -> FD_FORM#2 recycles the same pbuf and re-links the same bio -> double-linked TAILQ element -> panic in fdstate on dequeue. Primitive beyond the panic: recycled pbuf's b_data/b_bcount are attacker-set and drive the FDC DMA (isa_dmastart at bp->b_data+fd->skip, b_bcount bytes) plus a final biodone()/devstat on the recycled buffer - kernel-memory corruption, but actor is already root so no uid0 chain is pursued.
Evidence (decisive lines)
run.log (fdform#1 EIO at 20.010/20.014 s, 2 runs), panic.txt (panic backtrace fdstate+0xdfb), run.patched_console.log + /tmp/uaffix.log lines in run.log (patched: rc=0 at 37.4/38.0 s, no panic), fix.diff
PoC changes
PoC authored from scratch for this finding (no seed): 128-reader saturation to hold the single-controller queue >20 s, legal nsecs=18 format (distinct from known DF-1364/2456), second FD_FORM to recycle the freed pbuf deterministically.
Verified recommended fix
Replace the 20 s biowait_timeout + manual biodone in fdformat() with an unbounded biowait(&bp->b_bio1, "fdform") so the bio stays owned by the driver until it is really completed.
Verdict
Deterministic 2/2: with the shared fdc queue stalled >20 s by 128 concurrent readers, fdformat()'s biowait_timeout fires and fdformat() itself biodone()s the bio the driver still owns and relpbuf()s the pbuf; the recycled pbuf is re-queued by a second FD_FORMAT while the stale entry is still linked, and the next dequeue panics the kernel ('Bad link elm ... next->prev != elm' in fdstate via fdc_intr). Root-only trigger (FD_FORM needs write open, 0640 root:operator). Fix (plain biowait, ownership respected) built in-guest and validated: formats return 0 after the real queue drain, no panic.
No comments yet.