hammer_vop_readdir OOB kernel-heap read / info leak via forged direntry data_len underflow
Summary
hammer_vop_readdir :1728 KKASSERT(cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF) β NO-OP on production kernels (no INVARIANTS). :1737 cursor.leaf->data_len - HAMMER_ENTRY_NAME_OFF(=16) passed as uint16_t d_namlen to vop_write_dirent. data_len is signed int32 from untrusted B-tree leaf metadata (hammer_btree.h:175). Crafted image data_len=8: (8-16)=-8 implicit conversion to uint16_t = 65528. vop_write_dirent (vfs_subr.c:2559) bcopy(d_name, dp->d_name, 65528) reads ~64KB from 16KB hammer_buffer->ondisk = ~48KB OOB kernel heap read. uiomove ships to user getdents = kernel memory info leak KASLR bypass credentials pointers. Or page fault on unmapped page = panic DoS. CRC gate does NOT stop: hammer_crc_test_leaf computes CRC over forged data_len=8 bytes attacker pre-computed CRC consumer length data_len-16 decoupled. Trigger: crafted HAMMER image mount then unprivileged user getdents/readdir. Fix: runtime if(data_len<=HAMMER_ENTRY_NAME_OFF||data_len>HAMMER_ENTRY_NAME_OFF+NAME_MAX) return EIO.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0769 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | HAMMER image forger: locates direntry leaves, sets data_len=8, recomputes leaf data_crc + node crc via the kernel's own iscsi_crc32 | 8.4 KB | view raw |
| icrc32.c | trigger-source | verbatim copy of sys/libkern/icrc32.c (userspace-buildable CRC32C, used by craft_img) | 43.0 KB | view raw |
| harness.c | trigger-source | deterministic transcription of vnops.c:1728-1738 + vfs_subr.c:2560-2577 proving the 49160-byte OOB read (production/INVARIANTS-OFF ceiling) | 5.5 KB | view raw |
| build.sh | build-script | cc -O2 -o harness harness.c ; cc -O2 -o craft_img craft_img.c icrc32.c | 624 B | view raw |
| run.sh | run-script | full pipeline: harness + create/forge HAMMER image + mount + readdir | 1.9 KB | view raw |
| fix.diff | suggested-fix | git-apply-able runtime bounds check on data_len before the KKASSERT/:1737 arithmetic | 877 B | view raw |
| VERDICT.md | verdict | full narrative: root cause, reproduction, impact, fix validation | 8.4 KB | β raw |
| README.md | readme | human-facing reproduce instructions | 1.6 KB | β raw |
| run.log | run-log | decisive baseline reproduction: harness output + craft_img output + GENERIC #0 panic signature | 3.8 KB | view raw |
| fix_build.log | build-log | single-fix kernel build output (nativekernel rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | post-fix readdir on #1 kernel: clean return, no panic, guest healthy | 773 B | view raw |
| panic.txt | panic-signature | GENERIC #0 KKASSERT panic block from boot.log (hammer_vop_readdir at vnops.c:1728) | 1.7 KB | view raw |
| env.txt | environment | uname, cc version, sysctls, INVARIANTS-in-GENERIC confirmation, hammer.ko loaded | 440 B | view raw |
| build.log | build-log | kernel build log excerpt proving -Werror clean compile of patched source | 146 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 |
DF-0769 β hammer_vop_readdir OOB read / info leak via forged direntry data_len
Bug: sys/vfs/hammer/hammer_vnops.c:1728-1738 β hammer_vop_readdir trusts the
on-disk int32_t data_len from a B-tree direntry leaf. The only guard is a KKASSERT
(no-op on production/INVARIANTS-OFF kernels). A forged data_len=8 underflows the name
length data_len - HAMMER_ENTRY_NAME_OFF(=16) to -8, narrowed to uint16_t d_namlen =
65528, driving a bcopy(...,65528) ~49 KB past the 16 KB hammer_buffer β OOB kernel-heap
read disclosed to userspace via getdirentries. On GENERIC (INVARIANTS ON) the KKASSERT
panics first (local DoS).
How to reproduce (inside the DragonFly guest, as root)
cd /root/df0769
./build.sh # builds harness + craft_img
./harness # Part 1: deterministic ~49160-byte OOB read proof
./run.sh # Part 2: creates 1GB HAMMER image, forges it, mounts, ls /mnt
# on GENERIC #0: PANIC at hammer_vnops.c:1728
# on a FIXED kernel: clean EIO return, no panic
The full panic signature is in panic.txt (from dfbsd-qemu/boot.log).
The fix validation (before/after) is in fix_run.log.
Files
craft_img.c/icrc32.cβ image forger (uses the kernel's owniscsi_crc32)harness.cβ deterministic OOB-read proof (production/INVARIANTS-OFF ceiling)fix.diffβ git-apply-able runtime bounds check (validated on a single-fix#1kernel)VERDICT.mdβ full narrativebuild.log/run.log/fix_build.log/fix_run.log/panic.txt/env.txt
DF-0769 β hammer_vop_readdir OOB kernel-heap read / info leak via forged direntry data_len
Verdict
REPRODUCED. On the GENERIC (INVARIANTS-ON) kernel the bug manifests as a kernel
panic (local DoS) at hammer_vnops.c:1728. On a production (INVARIANTS-OFF)
kernel the same code path performs a ~49 KB out-of-bounds kernel-heap read
disclosed to userspace via getdirentries (info leak). The fix.diff runtime
bounds check is validated: the panic is gone on a single-fix #1 kernel.
Root cause (confirmed line-by-line)
hammer_vop_readdir (sys/vfs/hammer/hammer_vnops.c) reads each directory entry
B-tree leaf and, trusting the on-disk int32_t data_len
(hammer_btree.h:175, sourced from untrusted B-tree metadata):
- vnops.c:1728 β
KKASSERT(cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF);This assertion is the only guard. It is a no-op on production kernels (compiled out withoutINVARIANTS).HAMMER_ENTRY_NAME_OFF= 16 (hammer_disk.h:963,offsetof(hammer_direntry_data, name[0])). - vnops.c:1737 β
cursor.leaf->data_len - HAMMER_ENTRY_NAME_OFFis passed as thed_namlenargument. The expression is computed inint, then implicitly narrowed touint16_tbyvop_write_dirent's prototype (vfs_subr.c:2560). For a forgeddata_len = 8:8 - 16 = -8β(uint16_t)-8 = 65528. - vfs_subr.c:2575 β
bcopy(d_name, dp->d_name, d_namlen)reads65528bytes starting atcursor.data->entry.name, which lives inside a 16 KBhammer_buffer->ondisk. The name begins at offset 16 in that buffer, so the bcopy reads16384 β 16 = 16368valid bytes then 49160 bytes past the buffer = OOB kernel-heap read. - vfs_subr.c:2577 β
uiomove(dp, len, uio)copies the whole dirent (_DIRENT_RECLEN(65528) = 65552bytes, including the OOB-read data) to the usergetdirentriesbuffer = info leak.
Why the CRC gate does not stop it
hammer_crc_test_leaf (hammer_crc.h:293) computes the CRC over
leaf->data_len bytes of the data (hammer_crc.h:274). For a forged
data_len = 8 the CRC covers only the first 8 bytes (the obj_id). An attacker
pre-computes data_crc = iscsi_crc32(direntry_data[0:8]), so the CRC test
passes β yet the consumer at vnops.c:1737 uses data_len β 16, which is
decoupled from the CRC'd length. The containing B-tree node CRC
(hammer_crc_get_btree, hammer_crc.h:224) must also be recomputed since
data_len lives inside the node; the patcher does both. This was verified
empirically: the patcher's self-check (ok=1) confirms the userspace
iscsi_crc32 matches the kernel's, and the mount proceeded cleanly with no
"CRC FAILED" message.
Reproduction
GENERIC (INVARIANTS-ON) manifestation β panic (this guest's #0 baseline)
- Image crafting (
craft_img.c, links the kernel's ownsys/libkern/icrc32.cfor userspace): a 1 GBnewfs_hammerimage is populated with two entries, unmounted, then binary-patched. The patcher scans every 4 KB B-tree leaf node, finds eachHAMMER_RECTYPE_DIRENTRY(0x0011) leaf element, setsdata_len = 8, recomputesdata_crc = iscsi_crc32(data[0:8]), and recomputes the node CRC. - Mount (root precondition β acceptable per the threat model: an admin
mounts a crafted filesystem image):
vnconfig -c vn0 img && mount -t hammer -o nohistory /dev/vn0 /mnt. - Trigger (unprivileged):
ls /mntβgetdirentriesβhammer_vop_readdirβKKASSERT(8 > 16)fails.
Panic signature (from dfbsd-qemu/boot.log, full block in panic.txt):
panic: assertion "cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF" failed in hammer_vop_readdir at /usr/src/sys/vfs/hammer/hammer_vnops.c:1728 hammer_vop_readdir() at hammer_vop_readdir+0x294 vop_readdir() at vop_readdir+0x6b kern_getdirentries() at kern_getdirentries+0xdc sys_getdirentries() at sys_getdirentries+0x24 syscall2() at syscall2+0x11e Stopped at Debugger+0x7c
The stack names hammer_vop_readdir at vnops.c:1728 reached via the
unprivileged getdirentries syscall β exactly the cited path.
Production (INVARIANTS-OFF) manifestation β ~49 KB info leak
harness.c transcribes vnops.c:1737's length arithmetic and
vfs_subr.c:2575's bcopy verbatim against a poisoned 16 KB "hammer_buffer"
plus adjacent sentinel "heap". Output (deterministic):
forged data_len = 8 => d_namlen (uint16_t) = 65528 bcopy(d_name, dp->d_name, 65528): valid in buffer = 16368, OOB read = 49160 bytes => ~49160-byte kernel-heap INFO LEAK via unprivileged getdirentries
This is the production ceiling: on INVARIANTS-OFF kernels the KKASSERT is
absent, the bcopy runs, and uiomove ships the OOB bytes to userland. The leak
exposes neighbouring slab/page data β kernel pointers (KASLR defeat, though KASLR
is OFF on this guest regardless), struct ucred * / struct file * pointers,
and other kmalloc bucket contents. This is a read-only primitive (the bcopy
writes into a freshly kmalloc'd dirent that is immediately kfree'd after
uiomove); it does not corrupt kernel state, so no escalation chain applies
(valid hard blocker: read-only primitive).
Impact
- GENERIC (default kernel, INVARIANTS ON): local panic / DoS. Trigger:
unprivileged
getdirentries/lson a directory of a mounted crafted HAMMER v1 image. Mount is the only root precondition. - Production (INVARIANTS OFF): ~49 KB kernel-heap info leak per
getdirentriescall, repeatable, exposing kernel pointers and credential structures. Read-only β nouid=0chain.
HAMMER v1 is shipped as a loadable module (hammer.ko, auto-loaded on mount);
mount_hammer / newfs_hammer are in /sbin. No exotic config required.
Threat model / preconditions
- An admin has mounted (or made mountable) a crafted HAMMER v1 filesystem image
and made its directory readable (acceptable precondition, consistent with the
audit's realism test).
vfs.usermountis OFF, so the mount itself is root; the trigger (getdirentries) is fully unprivileged. - Alternatively a physically malicious disk / filesystem image presented to the kernel produces the same effect on mount + first directory read.
Exploit chain
None β read-only info-leak / panic primitive. No write, no corruption, so there is no privilege-escalation chain to develop (valid hard blocker per Phase 6: "the primitive is genuinely read-only"). The production ceiling is the ~49 KB kernel-heap disclosure (KASLR defeat + credential-pointer leak).
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 direntry leaves, sets
data_len=8, recomputes leaf data_crc + node crc via the kernel's own
iscsi_crc32). Self-validates its CRC against the on-disk values before patching.
- icrc32.c β verbatim copy of sys/libkern/icrc32.c (userspace-buildable CRC32C).
- harness.c β deterministic transcription of vnops.c:1728-1738 +
vfs_subr.c:2560-2577 proving the 49160-byte OOB read (production ceiling).
- build.sh, run.sh β exact build/run commands.
- fix.diff β runtime bounds check (see below).
Recommended fix (fix.diff)
Add a runtime bounds check before the KKASSERT and the :1737 arithmetic:
if (cursor.leaf->data_len <= HAMMER_ENTRY_NAME_OFF ||
cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF + NAME_MAX) {
error = EIO;
break;
}
This rejects forged/impossible direntry data_len values with EIO, preventing
both the underflow and the OOB read. The KKASSERT is retained as
defense-in-depth (now provably-true after the guard). Matches the finding
markdown's ## Recommended fix proposal. Validated by Phase 8 (below).
Phase 8 β fix validation (single-fix kernel)
- Baseline
#0(with-src, INVARIANTS ON, unpatched):ls /mnton the crafted image β panic athammer_vnops.c:1728(guest wedged in DDB). - Single-fix
#1(same tree +fix.diffonly,kern.version=6.5-DEVELOPMENT #1: Sun Jul 5 11:11:26 UTC 2026,sha256 = 56bd513b...): identical crafted image, identicalls /mntβ clean return (total 0,LS_RC=0), no panic, guest healthy across 3 runs.stat /mntsucceeds.
The fix closes the bug: the forged direntry is rejected with EIO before
reaching the KKASSERT/underflow, on both GENERIC and production kernels.
See fix_build.log (kernel build rc=0) and fix_run.log (post-fix readdir,
no panic).
Fix verification
fixedVALIDATED. Baseline #0 (with-src, INVARIANTS ON, unpatched): identical crafted HAMMER image, ls /mnt -> panic at hammer_vnops.c:1728, guest wedged in DDB (before marker). Single-fix #1 kernel (same tree + fix.diff only, nativekernel rc=0): identical crafted image, ls /mnt -> clean return (total 0, LS_RC=0), NO panic, guest healthy across 3 runs; stat /mnt succeeds (after marker). The runtime bounds check rejects the forged direntry (data_len=8 <= 16) with EIO before the KKASSERT/underflow is reached, on both GENERIC and production kernels. fix.diff applies cleanly (git apply --check rc=0 on both host sys/ and guest /usr/src).
BEFORE (#0 baseline): 'panic: assertion "cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF" failed in hammer_vop_readdir at hammer_vnops.c:1728' / 'Stopped at Debugger+0x7c: db>' (guest down). AFTER (#1 fixed): 'ls -la /mnt' -> 'total 0' LS_RC=0; '11:18AM up 2 mins ...' guest up; boot.log has no panic/fatal-trap/assertion across 3 runs. Build: fix_build.log shows '=== NK_DONE rc=0 ===' / 'Kernel build for X86_64_GENERIC completed'. See findings/poc/DF-0769/fix_run.log + panic.txt + fix_build.log + fix.diff.
Confirmed kernel references
- sys/vfs/hammer/hammer_vnops.c:1728
- sys/vfs/hammer/hammer_vnops.c:1737
- sys/kern/vfs_subr.c:2560
- sys/kern/vfs_subr.c:2575
- sys/vfs/hammer/hammer_btree.h:175
- sys/vfs/hammer/hammer_disk.h:963
- sys/vfs/hammer/hammer_crc.h:274
- sys/vfs/hammer/hammer_crc.h:293
- sys/vfs/hammer/hammer_btree.c:736
- sys/vfs/hammer/hammer_btree.c:764
Detail
Exploit chain
none -- read-only info-leak / panic primitive (valid Phase-6 hard blocker: the primitive is genuinely read-only; the bcopy writes into a freshly kmalloc'd dirent that is kfree'd after uiomove, corrupting no kernel state). No escalation chain applies. Production ceiling: ~49160 bytes of neighbouring kernel heap (slab/page data, kernel pointers, struct ucred */struct file * pointers) disclosed per getdirentries call -- KASLR defeat and credential-pointer leak, repeatable. GENERIC ceiling: local panic/DoS. Impact labeled accurately per the bright-line rule (INVARIANTS-ON = panic; INVARIANTS-OFF = info-leak).
Evidence (decisive lines)
findings/poc/DF-0769/ contains: panic.txt (GENERIC #0 KKASSERT block from boot.log naming hammer_vnops.c:1728), run.log (harness 49160-byte OOB proof + craft_img patcher output + baseline panic), harness.c (deterministic), craft_img.c+icrc32.c (image forger using the kernel's own iscsi_crc32, CRC self-check ok=1), fix.diff, fix_build.log (nativekernel rc=0), fix_run.log (#1 fixed kernel: ls /mnt returns clean, no panic). Panic signature: 'panic: assertion "cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF" failed in hammer_vop_readdir at /usr/src/sys/vfs/hammer/hammer_vnops.c:1728' / 'hammer_vop_readdir() at hammer_vop_readdir+0x294' / 'kern_getdirentries() at kern_getdirentries+0xdc'. Harness: '=> d_namlen (uint16_t) = 65528' / '>>> OOB READ beyond buffer = 49160 bytes <<<'.
PoC changes
Authored the entire evidence pack from scratch (folder was empty): craft_img.c (HAMMER image forger that locates direntry B-tree leaves, sets data_len=8, recomputes leaf data_crc + node crc via the kernel's own iscsi_crc32, with a CRC self-check that validates against on-disk values before patching); icrc32.c (verbatim copy of sys/libkern/icrc32.c, userspace-buildable); harness.c (deterministic transcription of vnops.c:1728-1738 + vfs_subr.c:2560-2577 proving the 49160-byte OOB read); build.sh, run.sh; fix.diff (runtime bounds check). The image-crafting approach: newfs_hammer a 1GB image, populate two entries, unmount, binary-patch the direntry leaves, recompute CRCs, remount, readdir.
Verified recommended fix
Add a runtime bounds check before the KKASSERT at hammer_vnops.c:1728: if (cursor.leaf->data_len <= HAMMER_ENTRY_NAME_OFF || cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF + NAME_MAX) { error = EIO; break; } -- this rejects forged/impossible direntry data_len values before the underflow, preventing both the panic (GENERIC) and the OOB read (production). The KKASSERT is retained as defense-in-depth. Matches the finding markdown's ## Recommended fix proposal. Full git-apply-able diff in findings/poc/DF-0769/fix.diff.
Verdict
REPRODUCED on GENERIC #0 (INVARIANTS ON) as a kernel panic and characterized on production (INVARIANTS OFF) as a ~49160-byte kernel-heap info leak. Confirmed the cited path line-by-line: hammer_vnops.c:1728 KKASSERT(data_len > HAMMER_ENTRY_NAME_OFF) is the ONLY guard and is a no-op on production kernels; :1737 computes (data_len - 16) which for a forged data_len=8 yields -8, implicitly narrowed to uint16_t d_namlen=65528 by vop_write_dirent (vfs_subr.c:2560); vfs_subr.c:2575 bcopy(d_name, dp->d_name, 65528) reads 65528 bytes from cursor.data->entry.name inside a 16KB hammer_buffer, i.e. 49160 bytes past it. The CRC gate (hammer_crc.h:293/274) is bypassed because it CRCs data_len (=8) bytes while the consumer uses data_len-16; the attacker pre-computes data_crc=iscsi_crc32(data[0:8]) (verified: craft_img self-check 'ok=1' confirms the userspace iscsi_crc32 matches the kernel). Triggered live: crafted a 1GB newfs_hammer image, binary-patched its direntry leaves to data_len=8 with recomputed leaf data_crc + node crc, mounted it (root precondition -- acceptable), then ls /mnt issued getdirentries -> panic 'assertion "cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF" failed in hammer_vop_readdir at hammer_vnops.c:1728' via the unprivileged getdirentries syscall (stack: hammer_vop_readdir -> vop_readdir -> kern_getdirentries -> sys_getdirentries -> syscall2). The deterministic harness.c transcribes the :1737 arithmetic + :2575 bcopy verbatim against a poisoned 16KB buffer and proves the 49160-byte OOB read (production ceiling).
No comments yet.