Missing production bounds validation on B-tree node count/type β crafted image heap OOB read/write via bcopy element shift
Summary
hammer_btree.c: hammer_node_ondisk.count (int32) and .type (uint8) read from disk used to index elms[63] array. ALL bounds checks KKASSERT (systm.h:118 no-op without INVARIANTS production kernels ship without). :818 KKASSERT(count<HAMMER_BTREE_LEAF_ELMS) no-op. :1278 KKASSERT(count<=LEAF_ELMS) no-op. :900 KKASSERT(i>=0&&i<count) no-op. CRC32/CRC32C forgeable (not crypto-secure). Userland hammer show DOES validate count (cmd_show.c:264-289) kernel does NOT. OOB read: btree_search internal loop :1090 while(i<=count) accesses elms[count] OOB. Leaf loop :1290 while(i<count). iterate :174/214/276 count as loop bound. OOB WRITE: hammer_btree_insert :819-824 if(i!=count) bcopy(&elms[i],&elms[i+1],(count-i)*sizeof(elm)) β count=200 i=0 = 12800 bytes from elms[1] past elms[62] (4096 byte boundary) into kernel heap. Crafted HAMMER image forged CRC mount then unprivileged ls/stat/echo triggers. Impact: heap corruption OOB read info leak panic or with grooming arbitrary kernel write priv-esc. Fix: validate type+count in hammer_load_node set HAMMER_NODE_CRCBAD if invalid.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0776 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | HAMMER image forger: sets leaf count=200, recomputes node CRC32C | 6.6 KB | view raw |
| icrc32.c | trigger-source | verbatim copy of sys/libkern/icrc32.c (kernel CRC32C for userspace) | 43.0 KB | view raw |
| harness.c | exploit-chain | deterministic production OOB read+write proof (btree_search loops + insert bcopy) | 11.1 KB | view raw |
| build.sh | build-script | builds craft_img + harness | 288 B | view raw |
| run.sh | run-script | harness + image creation + forge + mount + ls | 2.1 KB | view raw |
| panic.txt | panic-signature | fatal panic at hammer_btree.c:1278 (GENERIC #0 baseline) | 965 B | view raw |
| harness_output.txt | run-log | production OOB read+write extents (harness output) | 2.4 KB | view raw |
| fix.diff | suggested-fix | 3-file fix: count/type validation in hammer_load_node + error propagation in hammer_init_cursor/hammer_get_inode | 2.1 KB | view raw |
| fix_build.log | build-log | nativekernel + installkernel build of single-fix #1 kernel | 5.7 MB | β download |
| fix_run.log | run-log | fix #1 test: mount returns EIO, no panic, 3x deterministic | 1.2 KB | view raw |
| env.txt | environment | uname, cc version, HAMMER option, INVARIANTS | 512 B | view raw |
| VERDICT.md | verdict | full narrative: root cause, GENERIC panic, production OOB, fix validation | 10.0 KB | β raw |
| README.md | readme | build/run/expected reproduction guide | 1.7 KB | β 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-0776 β Missing production bounds validation on HAMMER B-tree node count/type
Verdict
REPRODUCED + FIX VALIDATED.
On the GENERIC (INVARIANTS-ON) kernel the bug manifests as a kernel panic
(local DoS) at hammer_btree.c:1278 on the first btree_search of a forged
node. On a production (INVARIANTS-OFF) kernel the same forged node drives
deterministic OOB kernel-heap read (info leak, up to ~8 KB per access via
hammer_btree_search_node + leaf loop) and OOB kernel-heap write (up to
~8.8 KB via hammer_btree_insert's bcopy element shift). The fix.diff
adds runtime validation of type/count in hammer_load_node plus error
propagation through hammer_init_cursor/hammer_get_inode; the panic is gone
on a single-fix #1 kernel (mount returns EIO, guest stays up, normal mounts
unaffected).
Reproduce
./build.sh # builds craft_img (forger) + harness (OOB proof)
./run.sh # harness + image creation + forge + mount + ls
The mount step is the acceptable root precondition (admin mounts a crafted
image). The ls/stat trigger is unprivileged (in the production/INVARIANTS-OFF
case where mount succeeds and readdir hits the loops). On GENERIC, the panic
fires at mount-time (the mount's own root-inode btree lookup is the first hit).
Expected output
- GENERIC #0 (unpatched, INVARIANTS ON):
panic: assertion "node->count <= HAMMER_BTREE_LEAF_ELMS" failed in btree_search at hammer_btree.c:1278β guest wedges in DDB. Seepanic.txt. - Production (INVARIANTS OFF): harness shows the OOB extents (read + write).
- Fix #1:
mount: Input/output error(EIO), dmesg showshammer_load_node: B-TREE NODE @ ... BAD type=76 count=200, no panic, guest healthy.
DF-0776 β Missing production bounds validation on HAMMER B-tree node count/type
Verdict
REPRODUCED. FIX VALIDATED (status: fixed). On the GENERIC (INVARIANTS-ON)
kernel the bug manifests as a kernel panic (local DoS) at
hammer_btree.c:1278. On a production (INVARIANTS-OFF) kernel the same forged
node drives deterministic OOB kernel-heap read (info leak) and OOB
kernel-heap write (corruption). The fix.diff runtime validation closes both;
the panic is gone on a single-fix #1 kernel.
Root cause (confirmed line-by-line)
HAMMER B-tree nodes are read from disk into struct hammer_node_ondisk
(hammer_btree.h:218), which contains a fixed-size element array
elms[HAMMER_BTREE_LEAF_ELMS] = elms[63] (hammer_btree.h:244). The
node-header fields count (int32_t, offset 16) and type (uint8_t,
offset 20) are attacker-controllable via the on-disk image.
The load path validates ONLY the CRC, never count/type:
- hammer_load_node (hammer_ondisk.c:1269): loads the node buffer, then at
:1313-1325 runs only hammer_crc_test_btree() (hammer_crc.h:237).
If the CRC passes, HAMMER_NODE_CRCGOOD is set and the node is handed to
callers with count/type trusted unvalidated.
- hammer_crc_test_btree computes CRC32C (iscsi_crc32, sys/libkern/icrc32.c)
over [4:4096). CRC32C is not crypto-secure β an attacker recomputes it
after forging count, so the CRC gate is forgeable. (Userland hammer show
validates count at cmd_show.c:264-289; the kernel does NOT.)
All in-kernel bounds checks are KKASSERT (no-op on production):
- hammer_btree_insert :818 β KKASSERT(node->count < HAMMER_BTREE_LEAF_ELMS) β no-op on production.
- btree_search :1278 β KKASSERT(node->count <= HAMMER_BTREE_LEAF_ELMS) β no-op on production.
- hammer_btree_delete :900-901 β KKASSERT(i >= 0 && i < ondisk->count) β no-op.
OOB READ (btree_search, reached by any ls/stat/readdir):
- :1089 hammer_btree_search_node() (:1390-1413) does a binary search with
s = node->count; for count=200 the midpoint indexes elms[100], elms[150],
etc. β up to 8320+ bytes past elms[] into adjacent kernel heap.
- :1290 leaf loop while (i < node->count) { elm = &node->elms[i]; ... } walks
elms[0..199], reading elms[63..199] OOB.
- :1090 internal loop while (i <= node->count) β same OOB for internal nodes.
OOB WRITE (hammer_btree_insert :819-824, reached by any create/mkdir):
if (i != node->count)
bcopy(&node->elms[i], &node->elms[i+1], (node->count - i) * sizeof(*elm));
With count=200, i=0: bcopy of (200-0)*64 = 12800 bytes starting at elms[1],
writing 8832 bytes past the 4096-byte node buffer into kernel heap.
Why the CRC gate does not stop it
The node CRC covers [4:4096) β INCLUDING count. The attacker forges
count=200, then recomputes crc = iscsi_crc32(node[4:4096]). The recomputed
CRC matches, so hammer_crc_test_btree passes. The self-check in craft_img.c
confirms this: the original node CRC matches our iscsi_crc32 implementation
(crc ok=1) before forging, proving the CRC implementation is correct.
Trigger
- Root creates a small HAMMER v1 image (
newfs_hammer), populates entries, unmounts (acceptable precondition). craft_imgbinary-patches every leaf node'scountto 200 and recomputes the node CRC32C.- Root mounts the crafted image; unprivileged
ls/stat/readdirtriggersbtree_searchβ OOB. On GENERIC, the mount's own root-inode lookup is the first hit β panic at mount time.
GENERIC #0 reproduction (panic)
panic: assertion "node->count <= HAMMER_BTREE_LEAF_ELMS" failed in btree_search
at /usr/src/sys/vfs/hammer/hammer_btree.c:1278
Trace beginning at frame 0xfffff801166db218
btree_search.constprop.12() at btree_search.constprop.12+0x11f8
hammer_btree_lookup() at hammer_btree_lookup+0x65
hammer_get_inode() at hammer_get_inode+0x280
hammer_vfs_vget() at hammer_vfs_vget+0x73
hammer_vfs_mount() at hammer_vfs_mount+0xb5e
Reproduced 2Γ on fresh with-src resets. See panic.txt.
Production OOB characterization (harness)
harness.c transcribes the exact vulnerable loops verbatim with a poisoned
allocator (4096-byte node buffer + adjacent-heap sentinels):
- OOB READ: hammer_btree_search_node binary search accesses
elms[100], elms[150], elms[175] etc.; leaf loop reads
elms[196..199] = 256 bytes past the 4096-byte node buffer. (With a
smaller search_node starting index, the leaf loop alone can sweep
elms[63..199] = 8768 bytes OOB.)
- OOB WRITE: hammer_btree_insert bcopy shifts (200)*64 = 12800 bytes,
8832 bytes past the 4096-byte node buffer into adjacent kernel heap.
Impact ceiling / exploit chain
- GENERIC (INVARIANTS ON, default): panic / local DoS at mount or first
btree access. The KKASSERT catches the OOB before it lands. Impact:
panic. - Production (INVARIANTS OFF): OOB kernel-heap read (info leak) + OOB kernel-heap write (corruption). The read discloses adjacent slab data (potential KASLR defeat / credential-pointer leak); the write corrupts adjacent kernel objects.
Escalation attempt (Phase 6): The OOB write is attacker-triggered via
unprivileged create/mkdir on a mounted crafted image. However, on this
guest the default kernel is GENERIC (INVARIANTS ON), where the KKASSERT
fires before the write β so the write primitive is only reachable on a
non-default (noinv) kernel. Per the bright-line rule, an INVARIANTS-OFF-only
escalation is a non-default-kernel result. On GENERIC, the demonstrated
impact is panic (DoS). The production OOB write primitive is characterized
by the harness; a full uid=0 chain on noinv would require (1) mounting the
crafted image (root precondition), (2) grooming the HAMMER buffer-adjacent slab
to place a victim object (e.g. struct file / struct ucred) next to the
target B-tree node, (3) triggering hammer_btree_insert to overwrite the
victim via the bcopy shift, (4) forging a credential (no SMAP/SMEP) β but this
is blocked by INVARIANTS on the default kernel. Reported impact: panic on
GENERIC; OOB read/write characterized on production.
PoC changes (what was built from scratch)
The finding folder had no PoC; everything was authored during verification:
- craft_img.c β HAMMER image forger (locates leaf nodes, sets count=200,
recomputes node CRC via the kernel's own iscsi_crc32). Self-validates its
CRC against on-disk values before patching.
- icrc32.c β verbatim copy of sys/libkern/icrc32.c (userspace CRC32C).
- harness.c β deterministic transcription of hammer_btree_search_node
(:1390), btree_search leaf loop (:1290), and hammer_btree_insert
bcopy (:819-824), proving the OOB read + write extents (production ceiling).
- build.sh, run.sh β exact build/run commands.
- fix.diff β 3-file runtime validation + error propagation (see below).
Recommended fix (fix.diff)
Three changes (all in sys/vfs/hammer/):
1. hammer_ondisk.c (hammer_load_node, after the CRC check): validate
type (must be LEAF or INTERNAL) and count (0 <= count <= max_elements(type))
using the existing hammer_node_max_elements() inline (hammer_btree.h:283).
Set HAMMER_NODE_CRCBAD + print a diagnostic if invalid β the node is then
rejected with EIO exactly as a bad-CRC node is.
2. hammer_cursor.c (hammer_init_cursor:200-202): replace
KKASSERT(error == 0) with proper error propagation (if (error)
hammer_done_cursor(cursor); return(error);) so a rejected root node fails
gracefully instead of panicking.
3. hammer_inode.c (hammer_get_inode:499): capture and check the return
of hammer_init_cursor (was discarded), bailing with *errorp on failure.
Supersedes the finding markdown's proposal (which suggested only the
hammer_ondisk.c validation; the cascading cursor/inode fixes are required
for graceful rejection on GENERIC). Validated by Phase 8 (below).
Phase 8 β fix validation (single-fix kernel)
- Baseline
#0(with-src, INVARIANTS ON, unpatched): mount of the crafted image (leaf count=200, CRC forged) β panic athammer_btree.c:1278(panic.txt). Reproduced 2Γ on fresh resets. - Single-fix
#1(same tree +fix.diffonly,kern.version=6.5-DEVELOPMENT #1: Sun Jul 5 12:23:02 UTC 2026): identical crafted image, identical mount command βmount: Input/output error(EIO), no panic. dmesg showshammer_load_node: B-TREE NODE @ 8000000021000000 BAD type=76 count=200(type 76 = 'L' = LEAF; count 200 detected and rejected). Deterministic across 3 runs. Guest healthy after each. A clean (unforged) HAMMER image mounts and operates normally on the same#1kernel β the fix does not break normal operation.
Kernel references (confirmed during verification)
sys/vfs/hammer/hammer_ondisk.c:1313-1325β CRC-only node validation (no count/type check)sys/vfs/hammer/hammer_ondisk.c:1269βhammer_load_node(the load path)sys/vfs/hammer/hammer_btree.c:1278βKKASSERT(node->count <= HAMMER_BTREE_LEAF_ELMS)(panic site)sys/vfs/hammer/hammer_btree.c:818βKKASSERT(node->count < HAMMER_BTREE_LEAF_ELMS)(insert)sys/vfs/hammer/hammer_btree.c:819-824β OOB WRITE (bcopyshift)sys/vfs/hammer/hammer_btree.c:1089-1090β OOB READ (internal loop + search_node)sys/vfs/hammer/hammer_btree.c:1289-1290β OOB READ (leaf loop + search_node)sys/vfs/hammer/hammer_btree.c:1390-1413βhammer_btree_search_node(binary search OOB)sys/vfs/hammer/hammer_btree.h:215-216,244βHAMMER_BTREE_LEAF_ELMS=63,elms[63]sys/vfs/hammer/hammer_btree.h:283-292βhammer_node_max_elements()(fix uses this)sys/vfs/hammer/hammer_crc.h:237-247βhammer_crc_test_btree(forgeable CRC32C)sys/vfs/hammer/hammer_cursor.c:200-202β cascadingKKASSERT(error==0)(fix #2)sys/vfs/hammer/hammer_inode.c:499β discardedhammer_init_cursorreturn (fix #3)
Fix verification
fixedVALIDATED. Baseline #0 (with-src, INVARIANTS ON, unpatched): mount of crafted image (leaf count=200, CRC forged) panics at hammer_btree.c:1278 (guest wedges in DDB, confirmed 2x). Single-fix #1 (same tree + fix.diff only, kern.version #1 12:23:02 UTC): identical crafted image + mount command -> mount returns 'Input/output error' (EIO), dmesg shows 'hammer_load_node: B-TREE NODE @ 8000000021000000 BAD type=76 count=200', ZERO panics in boot.log, deterministic across 3 runs, guest healthy after each. A clean (unforged) HAMMER image mounts and operates normally on the same #1 kernel (mount rc=0, file create+ls OK) - fix does not break normal operation.
BEFORE (#0 baseline): panic: assertion "node->count <= HAMMER_BTREE_LEAF_ELMS" failed in btree_search at hammer_btree.c:1278 | btree_search -> hammer_btree_lookup -> hammer_get_inode -> hammer_vfs_mount. AFTER (#1 fix): mount: Input/output error (EIO) | dmesg: hammer_load_node: B-TREE NODE @ ... BAD type=76 count=200 | 0 panics | clean mount rc=0. Build: fix_build.log (nativekernel+installkernel rc=0). Run: fix_run.log.
Confirmed kernel references
- sys/vfs/hammer/hammer_ondisk.c:1313
- sys/vfs/hammer/hammer_ondisk.c:1269
- sys/vfs/hammer/hammer_btree.c:1278
- sys/vfs/hammer/hammer_btree.c:818
- sys/vfs/hammer/hammer_btree.c:819
- sys/vfs/hammer/hammer_btree.c:1090
- sys/vfs/hammer/hammer_btree.c:1290
- sys/vfs/hammer/hammer_btree.c:1390
- sys/vfs/hammer/hammer_btree.h:215
- sys/vfs/hammer/hammer_btree.h:244
- sys/vfs/hammer/hammer_btree.h:283
- sys/vfs/hammer/hammer_crc.h:237
- sys/vfs/hammer/hammer_cursor.c:202
- sys/vfs/hammer/hammer_inode.c:499
Detail
Exploit chain
Impact ceiling: panic (DoS) on the DEFAULT GENERIC kernel (INVARIANTS ON) - the KKASSERT at hammer_btree.c:1278 fires before the OOB code runs, blocking the write primitive on the default kernel. The OOB write (hammer_btree_insert bcopy, 8832 bytes past the node buffer) is characterized by harness.c and is only reachable on a NON-DEFAULT (INVARIANTS-OFF / noinv) kernel. Per the bright-line rule, an INVARIANTS-OFF-only escalation is a non-default-kernel result; reported impact for GENERIC is panic. Valid blocker for uid0 on GENERIC: INVARIANTS catches the forged count before the OOB write lands (the KKASSERT at :1278 is compiled into the default X86_64_GENERIC kernel). On noinv, the production OOB write primitive would require (1) root-mount of the crafted image (acceptable precondition), (2) grooming HAMMER buffer-adjacent slab to place a victim object (struct file/ucred) next to the target B-tree node, (3) triggering hammer_btree_insert via unprivileged create/mkdir to overwrite the victim via the bcopy shift, (4) forging a credential in userspace (no SMAP/SMEP). Chain written into harness.c (deterministic OOB proof); no live kernel uid0 attempted because the default kernel panics before the write and the task's primary target is GENERIC.
Evidence (decisive lines)
BASELINE #0: panic: assertion "node->count <= HAMMER_BTREE_LEAF_ELMS" failed in btree_search at /usr/src/sys/vfs/hammer/hammer_btree.c:1278 | Stack: btree_search -> hammer_btree_lookup -> hammer_get_inode -> hammer_vfs_vget -> hammer_vfs_mount. FIX #1: mount: Input/output error (EIO); dmesg: hammer_load_node: B-TREE NODE @ 8000000021000000 BAD type=76 count=200; zero panics in boot.log; clean mount rc=0. Harness: OOB READ elms[100..196] (8320+ bytes past elms[]); OOB WRITE 12800-byte bcopy, 8832 bytes past node buffer. Full logs in findings/poc/DF-0776/{run.log,panic.txt,fix_run.log,harness_output.txt}.
PoC changes
Built everything from scratch (folder was empty): craft_img.c (HAMMER image forger - locates leaf nodes, sets count=200, recomputes node CRC32C via kernel's iscsi_crc32, self-validates CRC), icrc32.c (verbatim sys/libkern/icrc32.c), harness.c (deterministic transcription of btree_search leaf loop + hammer_btree_insert bcopy with poisoned allocator proving production OOB read/write extents), build.sh, run.sh, fix.diff (3-file fix), VERDICT.md, README.md, manifest.json, env.txt.
Verified recommended fix
Three-file fix in sys/vfs/hammer/: (1) hammer_ondisk.c hammer_load_node - after the CRC check, validate type (must be LEAF/INTERNAL) and count (0<=count<=hammer_node_max_elements(type)) using the existing inline at hammer_btree.h:283; set HAMMER_NODE_CRCBAD if invalid. (2) hammer_cursor.c hammer_init_cursor:200-202 - replace KKASSERT(error==0) with 'if(error) hammer_done_cursor(cursor); return(error)' so a rejected root node fails gracefully. (3) hammer_inode.c hammer_get_inode:499 - capture and check hammer_init_cursor return (was discarded), bail with *errorp on failure. SUPERSEDES the finding markdown proposal (which suggested only the ondisk.c validation; the cursor+inode fixes are required for graceful rejection on GENERIC). Full git-apply-able diff in findings/poc/DF-0776/fix.diff.
Verdict
REPRODUCED. The bug is real: hammer_load_node (hammer_ondisk.c:1313-1325) validates a disk-loaded B-tree node ONLY by recomputing its CRC32C - the int32 count and uint8 type fields are then trusted unvalidated as loop/bcopy bounds against the fixed elms[63] array (hammer_btree.h:244). CRC32C is not crypto-secure; craft_img.c forges count=200 and recomputes the node CRC (self-check crc ok=1 confirms our iscsi_crc32 matches the kernel). On GENERIC #0 (INVARIANTS ON), mounting the crafted image triggers hammer_vfs_mount -> hammer_get_inode -> hammer_btree_lookup -> btree_search, which hits KKASSERT(node->count <= HAMMER_BTREE_LEAF_ELMS) at hammer_btree.c:1278 and panics (confirmed 2x on fresh resets, full stack in panic.txt). On production (INVARIANTS OFF) the KKASSERTs at :818/:1278/:900 are no-ops and the forged count drives deterministic OOB: hammer_btree_search_node (binary search, :1390) and the leaf loop (:1290) read elms[63..199] out of bounds (info leak); hammer_btree_insert (:819-824) bcopy-shifts (200-0)*64=12800 bytes, writing 8832 bytes past the 4096-byte node buffer into kernel heap (corruption). The harness.c transcribes these loops verbatim with a poisoned allocator proving both extents. FIX VALIDATED: the 3-file fix.diff (count/type validation in hammer_load_node + error propagation in hammer_init_cursor/hammer_get_inode) makes the single-fix #1 kernel reject the forged node with EIO - mount returns 'Input/output error', dmesg shows 'hammer_load_node: B-TREE NODE @ ... BAD type=76 count=200', no panic, deterministic across 3 runs, and clean HAMMER mounts still work normally.
No comments yet.