# 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.
