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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0798 Β· 16 files
FileTypeDescriptionSize
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
README.md readme how to build/run/interpret the PoC
↓ download 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

  1. Creates a small valid HAMMER filesystem image (/build/df0798.img, 4 GB sparse).
  2. Mounts it briefly RW and populates it with a couple of files so the B-Tree root node is allocated and vol0_btree_root is set in the volume header.
  3. Unmounts.
  4. Reads the current vol0_btree_root (8-byte little-endian at struct offset 240 in the on-disk volume header β€” see sys/vfs/hammer/hammer_disk.h:776) and ORs its low 14 bits with 0x3FFC. 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.
  5. 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) calls hammer_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) returns EIO. No kernel message is printed at the default hammer_debug_critical level. 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;
}
VERDICT.md verdict full narrative: mechanism, evidence, fix validation
↓ download raw

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:

  1. 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).
  2. hammer_init_cursor calls hammer_get_node(trans, volume->ondisk->vol0_btree_root, 0, &error). hammer_get_node asserts only the zone bits via hammer_is_zone_btree() (sys/vfs/hammer/hammer_ondisk.c:1214); it never checks the in-buffer offset.
  3. hammer_load_node (sys/vfs/hammer/hammer_ondisk.c:1306) computes: c node->ondisk = (void *)((char *)buffer->ondisk + (node->node_offset & HAMMER_BUFMASK)); With offset & 0x3FFF = 0x3FFC, node->ondisk points 4 bytes before the END of the 16384-byte buffer.
  4. hammer_load_node immediately calls hammer_crc_test_btree() (line 1315), which at sys/vfs/hammer/hammer_crc.h:227 does: c hammer_datacrc(vol_version, &node->crc + 1, HAMMER_BTREE_CRCSIZE); HAMMER_BTREE_CRCSIZE is sizeof(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 at node->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_crc32c when 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 without vfs.usermount=1 plus 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 current vol0_btree_root (8-byte LE at struct offset 240 in the on-disk volume header) and ORs its low 14 bits with 0x3FFC, 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 with cc -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.)

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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 22:04:04 UTC 2026 (sha256 08cbe26a...)

Confirmed kernel references

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.