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)
PoC verification
Evidence pack
findings/poc/DF-0771 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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=0on 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).
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=0on 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_atimemap to the corruptedinode_data.mtime/atime) and viareadlink()(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): PoCreproducer-live.sh oobβ panichammer_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, sha256acfae4f3β¦): 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
fixedVALIDATED 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.
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.
No comments yet.