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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4063 Β· 12 files
FileTypeDescriptionSize
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

  1. 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 launcher dfbsd-qemu/run_fdtest.sh, vm.sh untouched).
  2. ./build.sh β†’ cc -O2 -pthread -o /tmp/fd_timeout_uaf fd_timeout_uaf.c
  3. As root: /tmp/fd_timeout_uaf 128
  4. 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.
VERDICT.md
↓ download raw

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)

sys/dev/disk/fd/fd.c:

  • fdformat() (sys/dev/disk/fd/fd.c:2244) submits a BUF_CMD_FORMAT bio through dev_dstrategy() (fd.c:2276). The bio is now owned by the fdc state machine β€” it sits in fdc->bio_queue (linked by bioq_insert_tail() via fdstrategy()'s bioqdisksort() default case, fd.c:1530) until the controller works its way to it.
  • fdformat() waits with biowait_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 then relpbuf(bp, NULL) (fd.c:2293) β€” while the fdc still holds the bio in its queue and the pbuf is still referenced by fdc->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

  1. biodone() runs bio_done (biodone_sync) and clears it; the bio keeps its stale links inside fdc->bio_queue (fdc->bio == &bp->b_bio1).
  2. relpbuf() returns the pbuf to the objcache. The very next getpbuf() β€” e.g. a second concurrent FD_FORMAT ioctl β€” typically recycles the same pbuf, so the stale fdc->bio pointer now aliases the live bio of the new ioctl.
  3. The new dev_dstrategy() re-inserts &bp->b_bio1 into fdc->bio_queue while the stale entry (same address!) is still linked β†’ the TAILQ element is linked twice.
  4. When the state machine dequeues (fdstate(), fd.c:1668-1672 bioq_remove()), the second TAILQ_REMOVE explodes: panic: Bad link elm ... next->prev != elm β€” and the interim completion runs biodone()/devstat_end_transaction_buf() on a pbuf that no longer belongs to this transfer (kernel UAF; tsleep error biowait 35 observed 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->status is u_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 plain callout_init() (not CALLOUT_MPSAFE) so softclock_handler() executes them with the mplock held (sys/kern/kern_timeout.c:575-584); fd_ops lacks D_MPSAFE so dev_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: dmesg interrupt 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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
↓ fix.diffper-fix-DF-4063

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.