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

Missing data_len validation on inode load β€” crafted image heap OOB read via struct copy past buffer end

Summary

hammer_get_inode :524-525 ip->ino_data=cursor.data->inode struct assignment 128 bytes NO validation data_len==sizeof(struct hammer_inode_data). CRC gate bypassable: hammer_crc_get_leaf (hammer_crc.h:267-270) returns 0 when data_len wrong for INODE records. hammer_crc_test_leaf :295 if(data_crc==0) test passes (0==0). Crafted image: data_offset near end of 16KB buffer (xoff>16256) data_len=1 data_crc=0. Struct copy reads 128 bytes past buffer into adjacent kernel heap. Leaked bytes populate inode fields (mode uid/gid UUIDs size ext.symlink[24] mtime atime) returned to userspace via stat()/readlink() = kernel heap info leak KASLR bypass. Requires mount capability (root/operator/removable media). Compare hammer_load_pseudofs :1030-1033 correctly clamps bytes. Fix: if(data_len!=sizeof(struct hammer_inode_data)) *errorp=EIO + hammer_crc.h return (hammer_crc_t)-1 not 0 for wrong data_len.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0771 Β· 16 files
FileTypeDescriptionSize
df0771_harness.c trigger-source deterministic userspace harness: real kernel structs + verbatim CRC inline logic; proves CRC bypass + struct-copy OOB 11.1 KB view raw
corrupt.c trigger-source crafted-HAMMER-image corruptor: walks B-Tree, rewrites root inode leaf data_len=1/data_crc=0, bumps xoff, recomputes node CRC 8.5 KB view raw
icrc32_kern.c trigger-source userspace build of sys/libkern/icrc32.c (real iscsi_crc32) linked into corrupt 43.1 KB view raw
build.sh repro-script builds harness + corruptor (unprivileged) 444 B view raw
run.sh repro-script runs the deterministic harness (unprivileged) 703 B view raw
reproducer-live.sh repro-script live-path reproducer (root): create+corrupt HAMMER image, mount -> panic on unpatched / EIO on patched 1.6 KB view raw
run.log run-log harness output on baseline #0 1.3 KB view raw
panic.txt panic-signature Fatal trap 12 page fault at hammer_get_inode+0x400 movq 0x78(%rax),%rax (baseline #0, OOB variant) 672 B view raw
fix.diff suggested-fix validate cursor.leaf->data_len == sizeof(struct hammer_inode_data) before struct copy; set EIO 1.6 KB view raw
fix_build.log build-log single-fix nativekernel build output (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel: mount -> EIO, dmesg 'bad inode data_len 1', guest up 297 B view raw
env.txt environment uname, cc version, vfs.usermount 249 B view raw
VERDICT.md verdict full narrative: mechanism, threat model, impact ceiling, fix validation 8.2 KB ↓ raw
README.md readme human repro index 2.1 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 human repro index
↓ download raw

DF-0771 β€” Missing data_len validation on HAMMER inode load

Title: Missing data_len validation on inode load β€” crafted image heap OOB Severity: Medium Class: heap OOB read / info leak via crafted filesystem image (mount-time parsing)

Reproduce

Deterministic harness (unprivileged, no kernel effects)

./build.sh && ./run.sh

Builds df0771_harness (userspace; copies the real kernel structs and the verbatim CRC inline logic) and prints both halves of the bug: - CRC gate bypassed for an INODE leaf with data_len != sizeof(inode_data) and data_crc = 0 (hammer_crc.h:267 returns 0, matches on-disk 0). - Struct copy at hammer_inode.c:525 reads 128 bytes unconditionally; with xoff > 16256 it reads past the 16 KiB HAMMER data buffer.

Live kernel path (needs root β€” threat model is admin mounting attacker media)

sudo ./reproducer-live.sh oob    # OOB variant -> panic on unpatched, EIO on patched
sudo ./reproducer-live.sh        # non-OOB CRC-bypass variant -> mounts on unpatched, EIO on patched

Creates a HAMMER image, corrupts the root-inode B-Tree leaf (data_len=1, data_crc=0, bumps data_offset xoff), recomputes the B-Tree node CRC using the real iscsi_crc32 from sys/libkern/icrc32.c, then mounts.

Expected results

kernel OOB variant (oob) non-OOB variant
unpatched (#0) panic hammer_get_inode+0x400: movq 0x78(%rax),%rax mounts (CRC bypass); root inode loads
patched (#1) mount: Input/output error; dmesg bad inode data_len 1; guest up mount: Input/output error; guest up

Build / run commands (exact)

  • build: cc -O2 -Wall -o df0771_harness df0771_harness.c && cc -O2 -Wall -o corrupt corrupt.c
  • run (harness): ./df0771_harness
  • run (live, as root): ./reproducer-live.sh oob

Preconditions

  • Harness: none (unprivileged userspace).
  • Live path: root (vnconfig/newfs_hammer/mount_hammer). vfs.usermount=0 on this guest; the realistic threat is an admin/operator mounting attacker-supplied removable media.

Fix

See fix.diff (validated: single-fix kernel rejects the crafted image with EIO; full before/after in VERDICT.md).

VERDICT.md verdict full narrative: mechanism, threat model, impact ceiling, fix validation
↓ download raw

DF-0771 β€” Missing data_len validation on HAMMER inode load β€” crafted-image heap OOB read

Verdict: REPRODUCED (info-leak / heap OOB read) β€” and FIX VALIDATED.

The bug (one paragraph)

hammer_get_inode() (sys/vfs/hammer/hammer_inode.c:525) loads an inode from disk with an unconditional 128-byte struct copy:

523:  if (*errorp == 0) {
524:      ip->ino_leaf = cursor.node->ondisk->elms[cursor.index].leaf;
525:      ip->ino_data = cursor.data->inode;     /* sizeof = 128 bytes */

cursor.data is set by hammer_btree_extract() (hammer_btree.c:737) to the return value of hammer_bread_ext(hmp, data_off, data_len, ...), which is (char *)buffer->ondisk + xoff where xoff = data_off & HAMMER_BUFMASK (hammer_ondisk.c:1116-1170). The buffer is HAMMER_BUFSIZE (16384) bytes. The only length validation on the whole load path is the KKASSERT at hammer_btree.c:734:

KKASSERT(data_len >= 0 && data_len <= HAMMER_XBUFSIZE);   /* <= 65536 */

i.e. the on-disk data_len is never checked against sizeof(struct hammer_inode_data) (128). The struct copy therefore always reads 128 bytes from cursor.data; when the attacker-controlled data_offset's within-buffer xoff is > 16384 - 128 = 16256, the copy runs past the 16 KiB data buffer into adjacent kernel heap.

Why the CRC gate does not catch it

hammer_crc_test_leaf() (hammer_crc.h:294) compares the on-disk data_crc against hammer_crc_get_leaf(). For an HAMMER_RECTYPE_INODE record, hammer_crc_get_leaf() (hammer_crc.h:267-271) does:

case HAMMER_RECTYPE_INODE:
    if (leaf->data_len != sizeof(struct hammer_inode_data))
        return(0);   /* "This shouldn't happen" */

i.e. a wrong-sized INODE record makes hammer_crc_get_leaf() return 0. A crafted image that also sets the on-disk data_crc = 0 then satisfies leaf->data_crc == hammer_crc_get_leaf() as 0 == 0 β†’ the CRC test passes and the wrong-sized record is accepted. (Confirmed in the harness for both vol_version 6 and 7.)

Reproduction β€” deterministic harness (df0771_harness.c)

A self-contained userspace program that copies the real kernel structs (hammer_inode_data, hammer_btree_leaf_elm) and the verbatim CRC inline logic from hammer_crc.h, then proves both halves of the bug:

sizeof(struct hammer_inode_data) = 128
CRC test for INODE leaf with data_len=1, data_crc=0:
   vol_version=6 -> PASS      vol_version=7 -> PASS
BUG PART 1 CONFIRMED: CRC gate bypassed
TEST 2: xoff=16280, struct copy reads 128 bytes at [16280..16408)
   OOB region = [16384..16408) = 24 bytes past buffer end.
   inode_data.atime (last 8 bytes, all in OOB region) hex: 77 77 77 77 77 77 77 77
BUG PART 2 CONFIRMED: struct copy at hammer_inode.c:525 read 24 bytes
   past the 16 KiB HAMMER data buffer end into adjacent kernel heap.
==== DF-0771 REPRODUCED (info-leak / heap OOB read) ====

Reproduction β€” live kernel path (reproducer-live.sh)

A real HAMMER filesystem image is created (newfs_hammer), then corrupt (which links the real iscsi_crc32 from sys/libkern/icrc32.c) walks the B-Tree, finds the root-inode leaf (obj_id=1, rec_type=INODE), rewrites data_len = 1, data_crc = 0, bumps data_offset's xoff to 16264 (so the 128-byte struct copy overruns the 16 KiB buffer by 8 bytes β€” the atime field lands entirely past the end), recomputes the B-Tree node CRC, and writes it back. Mounting the image:

UNPATCHED baseline kernel (6.5-DEVELOPMENT #0): the kernel panics in hammer_get_inode reading atime past the buffer into an unmapped page:

Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff80061e2a000
fault code             = supervisor read data, page not present
instruction pointer    = 0x8:0xffffffff80938a90
Stopped at hammer_get_inode+0x400:  movq 0x78(%rax),%rax

movq 0x78(%rax),%rax loads the qword at offset 0x78 = 120 β€” that is the atime field (the last 8 bytes of the 128-byte struct hammer_inode_data), which with xoff = 16264 lands at buffer byte 16264 + 120 = 16384, i.e. the first byte past the buffer. The adjacent page is unmapped β†’ page fault. (Reproduced twice, identical RIP 0xffffffff80938a90.) With a mapped adjacent heap page the read would silently leak heap bytes into inode_data.atime/mtime, returned to userspace via stat()/readlink().

The non-OOB variant (data_len=1, data_crc=0, xoff=0) mounts successfully on the unpatched kernel β€” proving the CRC bypass accepts a wrong-sized INODE record on the live path; the root inode loads with data copied from the first 128 bytes of the buffer.

Threat model & impact ceiling

  • Threat model: crafted HAMMER filesystem image mounted by root / operator / removable-media auto-mounter (vfs.usermount=0 on this guest, so unprivileged mount is not possible; the realistic trigger is an admin mounting attacker-supplied media β€” the standard mount-time-parsing attacker-controlled-image model used across the audit).
  • Primitive class: read-only heap OOB read (info leak). The struct copy is a load, not a store; the bug gives no write/corruption primitive.
  • Impact ceiling: kernel heap info leak (KASLR-defeat / pointer-leak class) up to 128 bytes per crafted inode, observable via stat() (st_mtime/st_atime map to the corrupted inode_data.mtime/atime) and via readlink() (ext.symlink[24]). When the adjacent page is unmapped the read manifests as a kernel panic (local DoS by an admin who mounts attacker media).
  • Phase-6 escalation: no chain derivable β€” this is a genuinely read-only primitive (valid hard blocker per Phase 6). There is no write, no UAF, no refcount corruption, no type confusion; only an over-read. No privilege-escalation path exists from a pure info leak without a second, write-capable bug. The leak could assist a separate write primitive (e.g. defeat KASLR for a follow-on bug), but on this guest KASLR is already OFF, so even that assistance is moot here.

Fix (fix.diff)

Minimal, targeted: validate cursor.leaf->data_len == sizeof(struct hammer_inode_data) in hammer_get_inode() before the struct copy at line 525, setting *errorp = EIO (the existing error cleanup at lines 584-592 frees the inode and returns NULL). One logical change, 25 lines (mostly comment), in sys/vfs/hammer/hammer_inode.c.

(Supersedes the finding markdown's proposal, which also suggested changing hammer_crc.h to return (hammer_crc_t)-1. That CRC change is broader and risks side effects on every hammer_crc_get_leaf caller; the consumer-side check at the actual OOB site is the correct, targeted root-cause fix and is sufficient on its own β€” the wrong-sized record is now rejected at the only place the untrusted data_len feeds a fixed-size struct copy.)

Fix validation (Phase 8 β€” built, booted, re-run)

  • Baseline (#0, unpatched): PoC reproducer-live.sh oob β†’ panic hammer_get_inode+0x400: movq 0x78(%rax),%rax (OOB read into unmapped page). Non-OOB variant mounts successfully (CRC bypass confirmed live).
  • Single-fix kernel (#1, Thu Jul 9 17:18:43 2026, sha256 acfae4f3…): same PoC β†’ mount: Input/output error (EIO), guest stays up, dmesg shows the new guard firing: hammer_get_inode: bad inode data_len 1 for obj_id=0000000000000001. Both the OOB and the non-OOB variant are rejected. Fixed.

fix_status: fixed β€” clean before/after on the same PoC.

Files in this evidence pack

file what
df0771_harness.c deterministic userspace harness (real structs + verbatim CRC logic)
corrupt.c + icrc32_kern.c crafted-image corruptor (real iscsi_crc32 from libkern)
build.sh / run.sh repro scripts (harness path; unprivileged)
reproducer-live.sh live-path reproducer (needs root: vnconfig/newfs_hammer/mount_hammer)
run.log harness output on baseline (#0)
panic.txt kernel panic signature from boot.log (baseline #0, OOB variant)
fix.diff the validated fix (git-apply-able)
fix_build.log / fix_run.log single-fix kernel build log + patched-kernel run output
env.txt guest uname / cc version / sysctls
manifest.json machine-readable artifact catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. SAME PoC (reproducer-live.sh oob: crafted HAMMER image with root-inode leaf data_len=1, data_crc=0, data_offset xoff=16264) on the unpatched baseline #0 PANICS ('Stopped at hammer_get_inode+0x400: movq 0x78(%rax),%rax', page fault reading atime past the 16KiB buffer). On the single-fix kernel #1 (only sys/vfs/hammer/hammer_inode.c changed, rebuilt with make -j6 nativekernel) the SAME PoC is rejected cleanly: 'mount: Input/output error' (EIO), guest stays up, dmesg shows the new guard 'hammer_get_inode: bad inode data_len 1 for obj_id=0000000000000001'. The non-OOB CRC-bypass variant (data_len=1,data_crc=0,xoff=0) which MOUNTED SUCCESSFULLY on #0 is ALSO rejected with EIO on #1. Fix closes the bug deterministically (tested twice on the patched kernel).

BASELINE #0 (unpatched), ./reproducer-live.sh oob: 'Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80061e2a000 / Stopped at hammer_get_inode+0x400: movq 0x78(%rax),%rax' (guest down). NON-OOB variant on #0: 'mount_hammer /dev/vn0 /mnt/craft' succeeded, root inode loaded (CRC bypass). PATCHED #1 (sha256 acfae4f3...), same PoC: 'mount: Input/output error / MOUNT_RC=1' + dmesg 'hammer_get_inode: bad inode data_len 1 for obj_id=0000000000000001', guest up (uptime 1 min). Both OOB and non-OOB variants rejected on #1.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 9 17:18:43 UTC 2026 (sha256 /boot/kernel/kernel = acfae4f3dd7c3e14c7f9eb8305bf3187c7cc9dd6fb261a51f0322f055b73b4ea)

Confirmed kernel references

Detail

Exploit chain

none -- this is a genuinely read-only primitive (heap OOB read / info leak), which is a valid Phase-6 hard blocker. The struct copy 'ip->ino_data = cursor.data->inode' is a LOAD, not a store; the bug yields no write/UAF/refcount/type-confusion primitive, only an over-read of up to 128 bytes of adjacent kernel heap. The leaked bytes populate inode_data.{mode,uid,gid,size,ext.symlink[24],mtime,atime} returned to userspace via stat()/readlink() (KASLR-defeat / pointer-leak class, mount threat model: admin mounts attacker media). No privilege-escalation chain is derivable from a pure info leak without a separate write-capable bug -- and on this guest KASLR is already OFF, so even leak-assisted escalation is moot. Impact ceiling honestly documented as info-leak (or local DoS via panic when the adjacent page is unmapped).

Evidence (decisive lines)

HARNESS (baseline #0, unprivileged): 'CRC test for INODE leaf with data_len=1, data_crc=0: vol_version=6 -> PASS, vol_version=7 -> PASS / BUG PART 1 CONFIRMED: CRC gate bypassed' + 'TEST 2: xoff=16280 ... OOB region = [16384..16408) = 24 bytes past buffer end. inode_data.atime (last 8 bytes, all in OOB region) hex: 77 77 77 77 77 77 77 77 / BUG PART 2 CONFIRMED'. LIVE PATH (baseline #0, root mount of crafted image): 'Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80061e2a000 / supervisor read data, page not present / Stopped at hammer_get_inode+0x400: movq 0x78(%rax),%rax'. Non-OOB variant mounted successfully on #0 (CRC bypass confirmed live).

PoC changes

Created the full evidence pack from scratch (folder was absent on disk despite DB row). Authored: df0771_harness.c (deterministic userspace harness with the real kernel structs and verbatim hammer_crc.h inline logic, proving CRC bypass + struct-copy OOB); corrupt.c + icrc32_kern.c (crafted-HAMMER-image corruptor linking the real iscsi_crc32 from sys/libkern/icrc32.c, walks the B-Tree, rewrites the root inode leaf's data_len/data_crc/data_offset xoff, recomputes the B-Tree node CRC); build.sh/run.sh (unprivileged harness path) and reproducer-live.sh (root live-path reproducer); VERDICT.md, README.md, manifest.json, fix.diff. No changes to sys/ (read-only) or the finding markdown.

Verified recommended fix

In sys/vfs/hammer/hammer_inode.c, immediately before the 'if (errorp == 0)' block that does the struct copies at line 525, add: 'if (errorp == 0 && cursor.leaf->data_len != sizeof(struct hammer_inode_data)) { hdkprintf("bad inode data_len %d for obj_id=%016jx\n", ...); *errorp = EIO; }'. The existing error-cleanup path (lines 584-592) then frees the inode and returns NULL, so the wrong-sized record is rejected cleanly with EIO and the 128-byte struct copy never runs. Supersedes the finding markdown proposal (which also suggested changing hammer_crc.h to return (hammer_crc_t)-1); the consumer-side data_len check at the actual OOB site is the targeted root-cause fix and is sufficient on its own -- the broader CRC change was omitted to avoid side effects on every hammer_crc_get_leaf caller. Full git-apply-able diff in findings/poc/DF-0771/fix.diff.

Verdict

REPRODUCED. hammer_get_inode() (sys/vfs/hammer/hammer_inode.c:525) does an unconditional 128-byte struct copy 'ip->ino_data = cursor.data->inode' from a pointer set by hammer_bread_ext() to buffer->ondisk+xoff, with NO validation that the on-disk data_len == sizeof(struct hammer_inode_data). The only length check on the path is KKASSERT(data_len<=HAMMER_XBUFSIZE) at hammer_btree.c:734, so a crafted image with data_len!=128 + data_crc=0 bypasses the CRC gate (hammer_crc.h:267 returns 0 for a wrong-sized INODE record, matching on-disk 0 in hammer_crc_test_leaf:294), and the struct copy reads past the 16KiB HAMMER data buffer when data_offset's xoff>16256. Confirmed three ways: (1) deterministic userspace harness with the real kernel structs + verbatim CRC inline logic shows CRC bypass for vol_version 6&7 and a 24-byte over-read past the buffer; (2) live mount of a crafted HAMMER image (corruptor links the real iscsi_crc32 from sys/libkern/icrs32.c, recomputes the B-Tree node CRC) PANICS on the unpatched #0 kernel at 'hammer_get_inode+0x400: movq 0x78(%rax),%rax' (reading atime, offset 0x78=120, past the buffer into an unmapped page) -- reproduced twice, identical RIP; (3) the non-OOB variant (data_len=1,data_crc=0,xoff=0) MOUNTS SUCCESSFULLY on #0, proving the CRC bypass accepts a wrong-sized INODE record on the live path.