DragonFlyBSD Kernel Audit
DF-2661 / verdict.json
← back to finding ↓ download raw
{
  "finding_id": "DF-2661",
  "status": "reproduced",
  "reproduced": 1,
  "impact": "panic",
  "confidence": "certain",
  "verdict": "hammer2_io.c leaves dio->bp set when a device read fails (DIO_GOOD is gated on error==0 at io.c:366 but the errored buffer is only disposed at the refs 1->0 lastdrop in _hammer2_io_putblk). Any thread that took a DIO ref while the failing read held DIO_INPROG wakes, acquires INPROG, and trips KKASSERT(dio->bp == NULL) at io.c:254 -> kernel panic. Reproduced on the INVARIANTS guest with a sysctl-gated EIO injector (technique from DF-2644) plus 8 concurrent cat loops over cold inodes sharing one 64KB DIO window: panic 'dio->bp == NULL' in _hammer2_io_getblk (hammer2_io.c:264 = stock 254 + injector offset), stack hammer2_io_bread<-chain_load_data<-chain_lock<-inode_chain, guest wedged in ddb. Serial console even shows CPU1 printing the chain_load_data I/O error while CPU2 prints the panic - the two-actor race in the act. Fix (dispose bp on error before clearing INPROG) applied and validated: with the fix the assert never fires; after also fixing the unrelated DF-2662 NULL deref the identical 5-round EIO storm survives completely (ROUND_SURVIVED x5, run twice).",
  "exploit_chain": "N/A for privilege escalation: primitive is a state-machine violation leading to an assertion panic (INVARIANTS) or an orphaned buffer lock in the DOP_NEW path (non-INVARIANTS, traced, not built). No attacker-controlled memory content is involved; impact ceiling is local DoS when a device read error (dying disk, cable reset, degraded backend) coincides with concurrent metadata loads in the same 64KB window.",
  "evidence": "panic.txt (serial console: I/O-error kprintf interleaved with the 'dio->bp == NULL' panic, full trace, db> prompt); run_baseline.txt / run.log (run transcript); fix_run.log (kernel C: ROUND_SURVIVED x5 + ALL_ROUNDS_SURVIVED, RC=0, full untrimmed 9MB trace); build.log / fix_build.log (kernel B / kernel C builds); inject.diff (EIO injector gated to the 512MB test volume); VERDICT.md for the full narrative",
  "kernel_refs": [
    "sys/vfs/hammer2/hammer2_io.c:254",
    "sys/vfs/hammer2/hammer2_io.c:350-356",
    "sys/vfs/hammer2/hammer2_io.c:361-373",
    "sys/vfs/hammer2/hammer2_io.c:458-512",
    "sys/vfs/hammer2/hammer2_chain.c:985-1006",
    "sys/kern/vfs_bio.c:905-907"
  ],
  "poc_changes": "Seed sketch had to be rebuilt almost entirely: (1) vnconfig -T is O_TRUNC and silently zeroed every prepared image - images must be cloned with cp and attached WITHOUT -T; (2) hammer2 umount invalidates the raw-device buffer cache and the backing file only receives bdwrite'd data late, so umount/remount cycles on one attachment lose the FS - a golden image is materialized with dd if=/dev/vn2 while still attached and re-cloned per round; (3) cold chains require a fresh mount per round (chains persist while mounted), so each round = fresh clone + attach + mount; (4) plain ls warms the path walk without statting file inodes (keeping them cold); (5) trigger loops use recorded PIDs (sh job-control %N does not work non-interactively).",
  "attempts": 3,
  "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #2: Sun Aug 30 06:51:32 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (kernel C); baseline kernel A #1 Sun Aug 30 05:36:47 UTC 2026 (stock+inject), control/DF-2662 kernel B #1 06:22:36",
  "runtime_sec": 3120,
  "guest_dirty": 1,
  "build_cmd": "cd /usr/src && patch -p1 < /root/df2661/inject.diff && patch -p1 < /root/df2661/fix_b.diff && make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel KERNCONF=X86_64_GENERIC",
  "run_cmd": "sh /root/df2661/trigger_df2661.sh golden && sh /root/df2661/trigger_df2661.sh 5",
  "code_hash": "b9347d0e3e15dc53ad4c8f2af42baabe8f0b5c75e90cd08c6e044b2b49e4288b",
  "notes": "Attempts: (1) baseline kernel A panic (round<=3 of the storm); (2) fix kernel B - assert gone, storm then dies in the unrelated pre-existing hammer2_update_spans NULL deref (filed as DF-2662), ARM=0 control on kernel B survived 5/5; (3) kernel C (both fixes) survived the full storm twice. Guest reset with-src afterwards. Non-INVARIANTS kernels: traced - breadnx reuses a non-NULL *bpp so the READ path self-heals, but the DOP_NEW path overwrites dio->bp without brelse, orphaning the buffer lock.",
  "recommended_fix": "In _hammer2_io_getblk(), after dio->error = error: if (error && dio->bp) { brelse(dio->bp); dio->bp = NULL; } - restoring the invariant that a DIO with INPROG clear and GOOD clear has bp == NULL.",
  "fix_status": "fixed",
  "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #2: Sun Aug 30 06:51:32 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (kernel C = stock + inject + DF-2661 fix + DF-2662 fix)",
  "fix_baseline_reproduced": 1,
  "fix_patched_reproduced": 0,
  "fix_verdict": "Baseline kernel A panicked at KKASSERT(dio->bp == NULL) (hammer2_io.c:254/264). With fix applied (kernel B) the assert never fires again; the EIO storm then reaches an unrelated, pre-existing NULL deref in hammer2_update_spans (DF-2662). With both fixes (kernel C) the identical 5-round storm survives completely, twice: ROUND_SURVIVED x5, ALL_ROUNDS_SURVIVED, RC=0, EIOs surface as ordinary open() failures. fix_baseline_reproduced=1 (panic before fix), fix_patched_reproduced=0 (no panic after fix).",
  "fix_evidence": "fix_run.log (kernel C full storm survival, 9MB untrimmed); panic.txt (kernel A baseline); build.log/fix_build.log (kernel B/C builds); DF-2662 pack for the second bug's crash+fix"
}