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)
PoC verification
Evidence pack
findings/poc/DF-3068 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_offsetneed not be a record boundary. _hammer_check_signatureskips all head/tail agreement checks for PAD-8 records (:901-924) and re-derives the tail fromhead + hdr_size - 8(:948-961), so the tail bytes atend_off-8are never required to agree with the head the tail'stail_sizepoints 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'shdr_sizewhile the tail'stail_sizewas the only thing anchoring validation β a crafted "fake tail" whosetail_size(24) points at a 24-byte DUMMY head makes one loop iteration consume 24 bytes where only 8 remain inbytes.- Stage1's undo loop exits with
bytes = -16andKKASSERT(error || bytes == 0)at:460fires β 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 rtermKKASSERTat:1286when 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).
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 atend_off - tail->tail_sizeβ the tail's size β whilehammer_recover_scan_rev(:815) steps the walk back by the head'shdr_size. For PAD/DUMMY-style short records_hammer_check_signaturenever requires the previous record's tail to agree with the head it points at (:901-924skips the agreement block;:948-961re-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_sizeat:431).- The undo loop exits with
bytes = 8 - 24 = -16andKKASSERT(error || bytes == 0)at:460fires β 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
fixedIdentical 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
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.
No comments yet.