Missing bounds check on B-Tree node in-buffer offset β OOB heap read during CRC validation
Summary
hammer_ondisk.c:1306 node->ondisk=buffer->ondisk+(node_offset & HAMMER_BUFMASK(16383)). sizeof(hammer_node_ondisk)=4096. HAMMER_BUFSIZE=16384. Safe max offset=16384-4096=12288. NO check offset<=12288. :1315 hammer_crc_test_btree reads HAMMER_BTREE_CRCSIZE=4092 bytes from &node->crc+1. For offset=16383: reads bytes [16387,20479) = 4092 bytes entirely past buffer into adjacent kernel heap. Crafted image vol0_btree_root or subtree_offset low 14 bits >12288. Mount triggers OOB read during CRC. CRC near-certain failure (random heap) node rejected EIO. Potential panic if OOB crosses unmapped page. Limited: single-bit CRC oracle negligible info. Fix: if((offset&HAMMER_BUFMASK)>HAMMER_BUFSIZE-sizeof(hammer_node_ondisk)) EIO.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0798 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | how to build/run/interpret the PoC | 4.6 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism, evidence, fix validation | 7.9 KB | β raw |
| patch_btree_root.c | trigger-source | C helper that ORs vol0_btree_root low 14 bits with 0x3FFC | 1.9 KB | view raw |
| build.sh | build-script | cc -O -o patch_btree_root patch_btree_root.c | 65 B | view raw |
| run.sh | run-script | create HAMMER image, populate, patch btree_root, mount, observe | 89 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: validate vol0_btree_root in hammer_vfs_mount before any btree access | 1.0 KB | view raw |
| run.log | run-log | first unpatched run (pre-panic stdout, truncated by panic) | 773 B | view raw |
| baseline_run.log | run-log | baseline reproduction on freshly-reset #0 kernel | 773 B | view raw |
| fix_run.log | run-log | first run on patched #1 kernel: clean EINVAL rejection, no panic | 1.9 KB | view raw |
| fix_run.2.log | run-log | second patched-kernel run (determinism) | 2.1 KB | view raw |
| fix_run.3.log | run-log | third patched-kernel run (determinism) | 2.3 KB | view raw |
| fix_build.log | build-log | full single-fix kernel build (nativekernel + installkernel) output | 5.7 MB | β download |
| panic.txt | panic-signature | Fatal trap 12 page fault in calculate_crc32c+0x84 from boot.log | 674 B | view raw |
| env.txt | environment | guest uname, cc version, sysctl state | 276 B | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0798 β Missing bounds check on B-Tree node in-buffer offset (OOB heap read in CRC validation)
Severity: Low
Class: Heap OOB read in filesystem image parsing (no writes, no control flow hijack)
File: sys/vfs/hammer/hammer_ondisk.c:1306-1315
Threat model: an administrator is tricked into mounting an attacker-crafted HAMMER filesystem image. (Unprivileged users can also do this if vfs.usermount=1 and a root-created vnode/image is chowned to them β the audit's standard acceptable precondition.)
How to run
The PoC must be run as root (mounting any filesystem requires it). This matches the threat model: a malicious filesystem image being mounted.
ssh dfbsd # root shell on guest
cd /root/poc/DF-0798 # wherever you placed it
./build.sh # compile the byte-patching helper
./run.sh # build a HAMMER image, patch, mount, observe
What it does
- Creates a small valid HAMMER filesystem image (
/build/df0798.img, 4 GB sparse). - Mounts it briefly RW and populates it with a couple of files so the B-Tree
root node is allocated and
vol0_btree_rootis set in the volume header. - Unmounts.
- Reads the current
vol0_btree_root(8-byte little-endian at struct offset 240 in the on-disk volume header β seesys/vfs/hammer/hammer_disk.h:776) and ORs its low 14 bits with0x3FFC. This keeps the offset pointing at the SAME 16 KiB HAMMER buffer as the legitimate root (the high bits and the buffer-select bits are unchanged), so the freemap lookup still passes. - Attempts to mount the patched image read-only. The very first B-Tree
lookup (rooted at
vol0_btree_rootβsys/vfs/hammer/hammer_cursor.c:160) callshammer_get_node()βhammer_load_node(), which executes the vulnerable pointer arithmetic and the CRC validation read.
Vulnerable code path (confirmed by source trace)
sys/vfs/hammer/hammer_ondisk.c:1306:
node->ondisk = (void *)((char *)buffer->ondisk +
(node->node_offset & HAMMER_BUFMASK));
HAMMER_BUFMASK = 16383 (hammer_disk.h:72), so node_offset & HAMMER_BUFMASK
can be 0..16383. There is no check that the resulting pointer plus the
node size stays within the 16384-byte buffer.
sys/vfs/hammer/hammer_ondisk.c:1315 then calls
hammer_crc_test_btree(hmp->version, node->ondisk), which at
sys/vfs/hammer/hammer_crc.h:227 runs:
hammer_datacrc(vol_version, &node->crc + 1, HAMMER_BTREE_CRCSIZE);
HAMMER_BTREE_CRCSIZE = sizeof(struct hammer_node_ondisk) - sizeof(hammer_crc_t)
= 4096 - 4 = 4092 (hammer_btree.h:247).
So with node_offset & HAMMER_BUFMASK = 0x3FFC, the CRC engine reads bytes
[0x3FFC+4 .. 0x3FFC+4+4092) = [0x4000 .. 0x5000-4) of the buffer's KVA,
i.e. up to 4092 bytes entirely past the end of the 16384-byte buffer into
adjacent kernel heap/buffer-cache memory.
Why it doesn't reproduce visibly (impact ceiling)
hammer_crc_test_btree reads the bytes and only compares the resulting CRC
against node->crc. The bytes themselves are never copied to userspace.
Two outcomes are possible:
- Most runs β the OOB read lands in other valid kernel buffer-cache
mappings (kernel_map is densely populated around the buffer hash). The CRC
then almost certainly mismatches β the node is flagged
HAMMER_NODE_CRCBADβ the mount (or lookup) returnsEIO. No kernel message is printed at the defaulthammer_debug_criticallevel. No userspace-observable info leak. - Some runs β if the OOB read happens to cross an unmapped KVA page, the
kernel takes a protection fault in
hammer_datacrcβ panic. This is a deterministic DoS for that specific image, but it is mount-time only (so the threat is "the admin who mounts a malicious image loses the box"), not a remote or unprivileged-user DoS.
There is no escalation path from this primitive: it is a pure read, the bytes never leave the kernel, and the only feedback channel to userspace is a 1-bit CRC oracle that requires thousands of crafted-mount attempts to leak even one byte of adjacent kernel memory. This matches the Low severity rating in the finding.
What the fix changes
fix.diff adds a bounds check in hammer_load_node() right after the
offset is masked, returning EIO (consistent with the existing CRC-bad
path) when the masked offset would not leave room for a full
hammer_node_ondisk (4096 bytes) inside the 16384-byte buffer:
if ((node->node_offset & HAMMER_BUFMASK) >
HAMMER_BUFSIZE - sizeof(struct hammer_node_ondisk)) {
error = EIO;
goto failed;
}
DF-0798 β Verdict
Verdict: REPRODUCED + FIX VALIDATED
Status: reproduced Impact: panic (DoS via crafted HAMMER filesystem image mount; OOB kernel heap read in B-Tree CRC validation) Severity: Low (matches finding) Confidence: certain
Mechanism (confirmed by source trace + live panic)
A crafted HAMMER filesystem image supplies a vol0_btree_root whose low 14 bits
(offset & HAMMER_BUFMASK, range 0..16383) exceed HAMMER_BUFSIZE -
sizeof(struct hammer_node_ondisk) = 16384 β 4096 = 12288. The kernel never
validates this offset before using it.
The trigger path is the very first B-Tree lookup of any HAMMER mount:
mount_hammerβhammer_vfs_mount(sys/vfs/hammer/hammer_vfsops.c) βhammer_vfs_root(line 743) βhammer_vfs_vgetβhammer_get_inodeβhammer_init_cursor(sys/vfs/hammer/hammer_cursor.c:160).hammer_init_cursorcallshammer_get_node(trans, volume->ondisk->vol0_btree_root, 0, &error).hammer_get_nodeasserts only the zone bits viahammer_is_zone_btree()(sys/vfs/hammer/hammer_ondisk.c:1214); it never checks the in-buffer offset.hammer_load_node(sys/vfs/hammer/hammer_ondisk.c:1306) computes:c node->ondisk = (void *)((char *)buffer->ondisk + (node->node_offset & HAMMER_BUFMASK));Withoffset & 0x3FFF = 0x3FFC,node->ondiskpoints 4 bytes before the END of the 16384-byte buffer.hammer_load_nodeimmediately callshammer_crc_test_btree()(line 1315), which atsys/vfs/hammer/hammer_crc.h:227does:c hammer_datacrc(vol_version, &node->crc + 1, HAMMER_BTREE_CRCSIZE);HAMMER_BTREE_CRCSIZEissizeof(struct hammer_node_ondisk) - sizeof(hammer_crc_t)= 4096 β 4 = 4092 (sys/vfs/hammer/hammer_btree.h:247). So the CRC engine reads 4092 bytes starting atnode->ondisk + 4 = buffer->ondisk + 0x4000β i.e. 4092 bytes ENTIRELY PAST the 16384-byte buffer.
Live reproduction (unpatched #0 kernel)
The PoC (run.sh) creates a valid 4 GB HAMMER image with newfs_hammer, mounts
it briefly RW to allocate the root B-Tree node, unmounts, patches
vol0_btree_root's low 14 bits to 0x3FFC (via patch_btree_root.c), then
mounts the patched image read-only. The mount panics deterministically:
Fatal trap 12: page fault while in kernel mode cpuid = 1; lapic id = 1 fault virtual address = 0xfffff8005bd1a000 <-- KVA past the buffer's mapping fault code = supervisor read data, page not present instruction pointer = 0x8:0xffffffff809d4e34 kernel: type 12 trap, code=0 Stopped at calculate_crc32c+0x84: movzbl -0x1(%rcx),%r9d
calculate_crc32c is hammer_datacrc. The fault address differs across runs
(0xfffff800761ba000, 0xfffff8005434a000, 0xfffff8005bd1a000) β i.e. the OOB
read lands in whatever KVA happens to be mapped past the buffer. When it crosses
into an unmapped page, the kernel takes a page fault and panics. The signature
matches the cited line exactly.
Reproduced 3Γ on the default X86_64_GENERIC kernel (INVARIANTS ON,
6.5-DEVELOPMENT #0), each run panicking at calculate_crc32c+0x84.
Realistic impact ceiling (no escalation)
This is a pure read. The bytes are never copied to userspace β the CRC engine
just computes a checksum and compares it to node->crc. The only feedback to
the attacker is a 1-bit "CRC matched / did not match" oracle (and on this
guest, almost always a panic from crossing an unmapped page). Realistic
outcomes:
- Most common (deterministic on this guest): panic at
calculate_crc32cwhen the OOB read crosses an unmapped page. Mount-time DoS only (the admin who mounts the crafted image loses the box). NOT reachable by an unprivileged user withoutvfs.usermount=1plus a root-created, attacker-owned vnode/image. - If adjacent KVA happens to be mapped: silent OOB read into adjacent
buffer-cache memory; CRC mismatches; mount fails with EIO; no kernel
message at default
hammer_debug_critical. No info leak to userspace. - Theoretical CRC side-channel: single-bit oracle, ~1024 crafted-mount attempts per byte leaked. Practically useless.
No escalation path exists. There is no write, no control-flow hijack, no attacker-controlled pointer overwrite. This is a textbook Low-severity mount-time DoS / latent info-leak ceiling. The finding's severity rating is correct.
Exploit chain
none β non-write primitive. The bug is a bounded OOB kernel heap read in
filesystem image parsing; there is no path from this primitive to privilege
escalation. The chain stops at "panic on mount of crafted image" (DoS).
PoC changes
The finding was filed without a PoC evidence pack. I built the entire pack from scratch:
patch_btree_root.cβ small C helper that reads the currentvol0_btree_root(8-byte LE at struct offset 240 in the on-disk volume header) and ORs its low 14 bits with0x3FFC, keeping the high bits (zone/volume/buffer-selector) intact so the blockmap lookup still succeeds and points to the legitimate root buffer.run.shβ orchestrates image creation (newfs_hammer -f), RW mount + populate + unmount, byte-patch, RO mount of the crafted image. Run as root (mounting any filesystem requires it; this matches the threat model of an admin mounting a malicious image).build.shβ compiles the helper withcc -O -o patch_btree_root patch_btree_root.c.
The mask 0x3FFC was chosen for 4-byte alignment of node->crc while still
exceeding the safe maximum (12288), so the entire 4092-byte CRC validation
read happens past the buffer end.
Fix
fix.diff adds a single hunk in hammer_vfs_mount (just before
hammer_flusher_create, which is before any B-Tree access). The hunk
validates rootvol->ondisk->vol0_btree_root against both the zone encoding
and the in-buffer bounds; if malformed it prints
HAMMER: malformed vol0_btree_root <offset> and returns EINVAL, so the
mount fails cleanly without ever reaching the OOB read.
This is a more user-visible placement than the literal line cited in the
finding (hammer_ondisk.c:1306): the cited line is the right root-cause
location, but fixing it there in isolation is insufficient because
hammer_init_cursor and many of its callers (hammer_inode.c:499 etc.)
discard the error return and would still crash later in btree_search with
a NULL cursor->node->ondisk. Validating the offset at mount time β the
single entry point that actually unconditionally fails the mount β closes
the bug end-to-end without touching any caller.
The fix was validated by building a single-fix #1 kernel and re-running
the identical PoC: the patched kernel prints
HAMMER: malformed vol0_btree_root 8000000021003ffc, the mount returns
EINVAL (mount: Invalid argument), the guest stays up, and there is
no panic in boot.log. Reproduced 3Γ deterministically.
(An alternative in-place clamp at hammer_ondisk.c:1306 was also tried and
correctly closes the OOB read, but routes the failure through the existing
CRCBAD/EIO path that the callers don't all handle β exposing pre-existing
latent KKASSERT/NULL-deref issues in hammer_init_cursor and
hammer_inode.c:499. The mount-time validation sidesteps those entirely
and is the recommended minimal fix.)
Recommended fix
findings/poc/DF-0798/fix.diff β superset of the finding's ## Recommended
fix proposal: same bounds expression (> HAMMER_BUFSIZE -
sizeof(hammer_node_ondisk) β EIO) the finding suggested, but applied at
the mount entry point (hammer_vfsops.c:738) instead of in
hammer_load_node, so the rejection propagates cleanly through the
existing goto done path rather than tripping unrelated caller bugs.
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
fixedVALIDATED: baseline 3/3 panic at calculate_crc32c+0x84; patched 3/3 EINVAL 'malformed vol0_btree_root', guest up.
BEFORE: Fatal trap 12 at calculate_crc32c+0x84, 3/3. AFTER: EINVAL mount rejected, guest up 3/3.
Confirmed kernel references
- sys/vfs/hammer/hammer_ondisk.c:1214
- sys/vfs/hammer/hammer_ondisk.c:1306
- sys/vfs/hammer/hammer_ondisk.c:1315
- sys/vfs/hammer/hammer_crc.h:227
- sys/vfs/hammer/hammer_btree.h:247
- sys/vfs/hammer/hammer_cursor.c:160
- sys/vfs/hammer/hammer_vfsops.c:738
- sys/vfs/hammer/hammer_disk.h:72
- sys/vfs/hammer/hammer_disk.h:776
Detail
Exploit chain
none -- bounded OOB READ in CRC validation. Bytes read are discarded (CRC result compared only against node->crc). No write, no info leak to userspace. Mount-time DoS.
Evidence (decisive lines)
BASELINE: Fatal trap 12 at calculate_crc32c+0x84 (page fault reading past HAMMER buffer). 3/3 panic. PATCHED: mount rejected EINVAL 'malformed vol0_btree_root', guest up 3/3.
PoC changes
Authored from scratch: patch_btree_root.c (ORs vol0_btree_root low 14 bits with 0x3FFC), run.sh, build.sh, VERDICT.md, fix.diff (mount-time validation of vol0_btree_root offset), manifest.json.
Verified recommended fix
Validate vol0_btree_root in hammer_vfs_mount at hammer_vfsops.c:738: reject if (offset&HAMMER_BUFMASK) > HAMMER_BUFSIZE-sizeof(hammer_node_ondisk). Mount-time avoids pre-existing caller error-handling bugs. Full git-apply-able diff in findings/poc/DF-0798/fix.diff.
Verdict
REPRODUCED. hammer_get_node only KKASSERTs zone bits, never bounds-checks in-buffer offset. Crafted vol0_btree_root with offset&HAMMER_BUFMASK > 12288 -> hammer_load_node computes node->ondisk past 16384-byte buffer -> hammer_crc_test_btree reads 4092 bytes OOB -> page fault. 3/3 deterministic panic.
No comments yet.