β¬’ 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.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
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 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

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.