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.6 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 | 220 B | view raw |
| run.sh | run-script | create HAMMER image, populate, patch btree_root, mount, observe | 3.1 KB | 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 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.