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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0776 Β· 15 files
FileTypeDescriptionSize
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
README.md readme build/run/expected reproduction guide
↓ download 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. See panic.txt.
  • Production (INVARIANTS OFF): harness shows the OOB extents (read + write).
  • Fix #1: mount: Input/output error (EIO), dmesg shows hammer_load_node: B-TREE NODE @ ... BAD type=76 count=200, no panic, guest healthy.
VERDICT.md verdict full narrative: root cause, GENERIC panic, production OOB, fix validation
↓ download raw

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

  1. Root creates a small HAMMER v1 image (newfs_hammer), populates entries, unmounts (acceptable precondition).
  2. craft_img binary-patches every leaf node's count to 200 and recomputes the node CRC32C.
  3. Root mounts the crafted image; unprivileged ls/stat/readdir triggers btree_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).

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 at hammer_btree.c:1278 (panic.txt). Reproduced 2Γ— on fresh resets.
  • Single-fix #1 (same tree + fix.diff only, 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 shows hammer_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 #1 kernel β€” the fix does not break normal operation.

Kernel references (confirmed during verification)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 12:23:02 UTC 2026 (sha256 0feb7195b9c7fc2d95ee88c5b216afe0f3c7adf7dce76623fa591a0d0d6bcd55)

Confirmed kernel references

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.