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

Undo-walk desync via tail_size/hdr_size inconsistency drives bytes negative β€” deterministic KKASSERT(error || bytes == 0) panic in hammer_recover_stage1 (INVARIANTS) / silent mis-recovery on production kernels

Summary

hammer_check_tail_signature anchors the record head at end_off - tail->tail_size (:1023-1026) while hammer_recover_scan_rev steps the walk back by the head's hdr_size (:815); _hammer_check_signature never requires the previous record's tail to agree with the head it points at for PAD/DUMMY-style short records (:901-924 skip block, :948-961 tail re-derived from head). For version<4 mounts the nominal range comes verbatim from the crafted vol0_blockmap[3] (:225-226), so a fake tail whose tail_size=24 anchors a CRC-valid 24-byte DUMMY head while only 8 bytes remain makes one loop iteration subtract 24 from bytes=8 (:431) - the loop exits with bytes=-16 and KKASSERT(error || bytes == 0) at :460 panics. On production kernels the assert is compiled out: the mount silently succeeds, executes a record from outside the nominal undo range, and rewrites+flushes the volume-header blockmap (:481-488). VERIFIED twice on stock INVARIANTS guest: 'panic: assertion "error || bytes == 0" failed in hammer_recover_stage1 at hammer_recover.c:460', guest wedged at db>. No memory unsafety (all undo copies clamped at :1053-1076) - Low. Fix validated in-guest (reject hdr_size > remaining bytes in both loops): 'Corrupt UNDO record size' -> EIO, no panic, regressions pass.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3068 Β· 14 files
FileTypeDescriptionSize
padflood.c β€” 7.5 KB view raw
build.sh β€” 389 B view raw
run3068.sh β€” 735 B view raw
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 2.8 KB ↓ raw
run.log β€” 571 B view raw
panic.txt β€” 1012 B view raw
panic_console_full.txt β€” 2.3 KB view raw
env.txt β€” 613 B view raw
fix.diff β€” 3.1 KB view raw
fix_build.log β€” 5.1 MB ↓ download
fix_run.log β€” 1.3 KB view raw
manifest.json β€” 1.0 KB view raw
verdict.json β€” 5.1 KB view raw

DF-3068 β€” Undo-walk desync via PAD/fake-tail inconsistency β†’ deterministic KKASSERT panic in hammer_recover_stage1 (INVARIANTS) / silent mis-recovery (production)

Kernel: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), sys/vfs/hammer File under audit: sys/vfs/hammer/hammer_recover.c (pass 2). New finding β€” distinct root cause from DF-0812/2594 (redo_data_bytes) and DF-3040 (vol0_undo_array).

Root cause (hammer_recover.c)

  • For version < 4 filesystems the nominal undo range is taken verbatim from the crafted vol0_blockmap[3] (:225-226) β€” next_offset need not be a record boundary.
  • _hammer_check_signature skips all head/tail agreement checks for PAD-8 records (:901-924) and re-derives the tail from head + hdr_size - 8 (:948-961), so the tail bytes at end_off-8 are never required to agree with the head the tail's tail_size points at when that head is a short PAD/DUMMY-style record. hammer_recover_scan_rev (:1023-1026, :814-815) then steps the walk back by the head's hdr_size while the tail's tail_size was the only thing anchoring validation β€” a crafted "fake tail" whose tail_size (24) points at a 24-byte DUMMY head makes one loop iteration consume 24 bytes where only 8 remain in bytes.
  • Stage1's undo loop exits with bytes = -16 and KKASSERT(error || bytes == 0) at :460 fires β†’ deterministic panic on INVARIANTS kernels. On production kernels the assert is absent: the mount silently continues, executes an UNDO/DUMMY record from outside the nominal undo range, then rewrites and flushes the volume-header blockmap (:481-488) β€” silent mis-recovery of the FIFO indices. (The copies themselves stay buffer-bounded β€” no memory unsafety; impact is DoS on INVARIANTS + wrong recovery semantics on production.)
  • Sibling asserts reachable the same way: :394 (scan_offset != first_offset), stage2 :698/:716, and the rterm KKASSERT at :1286 when the stage2 forward walk desyncs against the reverse TERM scan.

Reproduce

# guest (root), after build.sh + run.sh of DF-3067 (vn0 wedged):
cp findings/poc/DF-3068/run.sh + padflood.c; sh build.sh (if needed)
sh run.sh          # padflood desync mode: vol_version=3, DUMMY@F-16 with
                  # fake tail at F (tail_size=24), first=F, next=F+8
# expected on the stock INVARIANTS guest:
#   Fatal trap ... assertion: error || bytes == 0 in hammer_recover_stage1
#   (guest wedged at db>)

Impact ceiling: deterministic mount-time panic (local DoS, crafted image; root or vfs.usermount=1). Low β€” INVARIANTS-only for the panic; production kernels get silent wrong recovery, no memory corruption (all undo copies are bounds-checked at :1053-1076).

VERDICT.md
↓ download raw

DF-3068 VERDICT β€” REPRODUCED (panic / dos)

Status: reproduced / impact: panic / confidence: certain. Baseline verified on the stock INVARIANTS kernel (DragonFly 6.5-DEVELOPMENT #0); fix validated on the rebuilt kernel #1.

What was run

padflood desync patches the same stock newfs_hammer image to vol_version=3 (version<4 mounts take first_offset/next_offset verbatim from vol0_blockmap[3], hammer_recover.c:225-226) and writes a 24-byte CRC-valid DUMMY record at FIFO offset 0xFF0 whose tail bytes ({0xC74F, 0x0041, 24} at 0x1000) double as the reverse walk's next fake tail; first=UNDO|0x1000, next=UNDO|0x1008 β†’ nominal undo range of 8 bytes.

Why it panics (path:line)

  • hammer_check_tail_signature (:1023-1026) anchors the head at end_off - tail->tail_size β€” the tail's size β€” while hammer_recover_scan_rev (:815) steps the walk back by the head's hdr_size. For PAD/DUMMY-style short records _hammer_check_signature never requires the previous record's tail to agree with the head it points at (:901-924 skips the agreement block; :948-961 re-derives the tail from the head), so a crafted tail/size pair whose head is a 24-byte record makes one loop iteration consume 24 bytes when only 8 remain (bytes -= head->head.hdr_size at :431).
  • The undo loop exits with bytes = 8 - 24 = -16 and KKASSERT(error || bytes == 0) at :460 fires β†’ deterministic panic.

Baseline observation (stock kernel #0, clean run after fresh reset)

HAMMER(uabase) recovery undo  3000000000001000-3000000000001008 (8 bytes)(RW)
panic: assertion "error || bytes == 0" failed in hammer_recover_stage1 at /usr/src/sys/vfs/hammer/hammer_recover.c:460
hammer_recover_stage1() at hammer_recover_stage1+0x2cb
hammer_vfs_mount() at hammer_vfs_mount+0xa3f
sys_mount() at sys_mount+0x36b
Debugger("panic")  -> guest wedged at db>

(reproduced twice; the first, accidental run hit the identical panic β€” see panic_console_full.txt vs panic.txt.)

Non-INVARIANTS behavior (production kernels)

The assert is compiled out: the loop exits silently with bytes < 0, the mount succeeds, stage1 has executed a record from outside the nominal undo range (the fake-tail DUMMY), and it rewrites + flushes the volume-header blockmap (:481-488) β€” silent mis-recovery of FIFO indices. No memory unsafety (all hammer_recover_undo copies are clamped at :1053-1076), hence severity Low for this finding.

Fix validation (fix.diff, kernel #1)

Size guard added (hdr_size > bytes β†’ EIO) before record execution. Identical PoC on the patched kernel: HAMMER(uabase) Corrupt UNDO record size 0018 at 3000000000000ff0 β†’ mount: Input/output error, no panic, guest up. Regressions (stock mount/RW cycle, hard-crash dirty recovery) all clean.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Identical PoC on the patched kernel: the new guard fires at exactly the crafted record ('Corrupt UNDO record size 0018 at 3000000000000ff0'), mount fails with I/O error, no panic, guest stays up (vm.sh status: up). Stock regressions pass incl. hard-crash dirty recovery.

fix_run.log, fix_build.log (kernel #1 build), fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 5 22:10:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['panic.txt (clean run): \'HAMMER(uabase) recovery undo  3000000000001000-3000000000001008 (8 bytes)(RW)\' then \'panic: assertion "error || bytes == 0" failed in hammer_recover_stage1 at /usr/src/sys/vfs/hammer/hammer_recover.c:460\' with trace hammer_recover_stage1 <- hammer_vfs_mount <- sys_mount <- syscall2, guest at db>', 'panic_console_full.txt: first (accidental) run - identical panic, proving determinism', "run.log: padflood desync craft output - 'DUMMY+fake-tail written at phys 0000000001088000 + ff0', 'undo AFTER first=3000000000001000 next=3000000000001008', 'vol_version=3 mode=desync crafted OK'", "fix_run.log (kernel #1 + fix.diff): 'Corrupt UNDO record size 0018 at 3000000000000ff0' + 'mount: Input/output error' MOUNT_RC=1, no panic, guest up; plus stock regressions", 'fix_build.log: make nativekernel KERNCONF=X86_64_GENERIC completed (kernel #1)']

PoC changes

Pass-2 author wrote the PoC from scratch (padflood desync mode). The DUMMY's tail bytes double as the next fake tail so one 24-byte record satisfies both the real-tail and fake-tail decodes; CRC computed as crc32(head[0..12)) ^ crc32(bytes[16..24)) per hammer_crc_get_fifo_head. Version patched 7->3 so the nominal range comes straight from the crafted blockmap; no vol_crc fixup needed (DF-3042).

Verified recommended fix

Reject records whose hdr_size exceeds the remaining recovery bytes (EIO) in stage1's undo loop and stage2's forward loop before executing them; see fix.diff

Verdict

REPRODUCED twice on the stock INVARIANTS kernel (second run after a fresh vm.sh reset with-src, clean sequence): mounting a crafted version-3 HAMMER image whose vol0_blockmap[3] names an 8-byte nominal undo range (first=UNDO|0x1000, next=UNDO|0x1008) containing a fake tail {0xC74F,0x0041,24} at 0x1000 that anchors a CRC-valid 24-byte DUMMY head at 0xff0 makes hammer_recover_stage1's undo loop consume 24 bytes where 8 remain (bytes -= hdr_size, hammer_recover.c:431) and KKASSERT(error || bytes == 0) at :460 panics: 'panic: assertion "error || bytes == 0" failed in hammer_recover_stage1 at hammer_recover.c:460', trace hammer_recover_stage1 <- hammer_vfs_mount <- sys_mount, guest wedged at db>. Root cause: hammer_check_tail_signature positions the head via the TAIL's size (:1023-1026) while hammer_recover_scan_rev steps via the HEAD's hdr_size (:815), and _hammer_check_signature never requires tail/head size agreement for PAD/DUMMY-style short records (:901-924 skip, :948-961 re-derive) - version<4 mounts take next_offset verbatim (:225-226). On production (non-INVARIANTS) kernels the assert is absent: the mount silently succeeds, executes a record from outside the nominal undo range, and rewrites+flushes the volume-header blockmap (:481-488) - silent mis-recovery, no memory unsafety (undo copies clamped at :1053-1076), hence Low severity. FIXED and validated: fix.diff adds hdr_size>bytes -> EIO guards in stage1's undo loop and stage2's fwd loop; on the rebuilt kernel #1 the identical image yields 'HAMMER(uabase) Corrupt UNDO record size 0018 at 3000000000000ff0' + 'mount: Input/output error', no panic, guest up; stock regressions (mount/RW cycle, hard-crash dirty recovery) all pass.