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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0769 Β· 16 files
FileTypeDescriptionSize
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
README.md readme human-facing reproduce instructions
↓ download 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 own iscsi_crc32)
  • harness.c β€” deterministic OOB-read proof (production/INVARIANTS-OFF ceiling)
  • fix.diff β€” git-apply-able runtime bounds check (validated on a single-fix #1 kernel)
  • VERDICT.md β€” full narrative
  • build.log / run.log / fix_build.log / fix_run.log / panic.txt / env.txt
VERDICT.md verdict full narrative: root cause, reproduction, impact, fix validation
↓ download raw

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 without INVARIANTS). 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_OFF is passed as the d_namlen argument. The expression is computed in int, then implicitly narrowed to uint16_t by vop_write_dirent's prototype (vfs_subr.c:2560). For a forged data_len = 8: 8 - 16 = -8 β†’ (uint16_t)-8 = 65528.
  • vfs_subr.c:2575 β€” bcopy(d_name, dp->d_name, d_namlen) reads 65528 bytes starting at cursor.data->entry.name, which lives inside a 16 KB hammer_buffer->ondisk. The name begins at offset 16 in that buffer, so the bcopy reads 16384 βˆ’ 16 = 16368 valid 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) = 65552 bytes, including the OOB-read data) to the user getdirentries buffer = 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)

  1. Image crafting (craft_img.c, links the kernel's own sys/libkern/icrc32.c for userspace): a 1 GB newfs_hammer image is populated with two entries, unmounted, then binary-patched. The patcher scans every 4 KB B-tree leaf node, finds each HAMMER_RECTYPE_DIRENTRY (0x0011) leaf element, sets data_len = 8, recomputes data_crc = iscsi_crc32(data[0:8]), and recomputes the node CRC.
  2. 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.
  3. 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/ls on 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 getdirentries call, repeatable, exposing kernel pointers and credential structures. Read-only β€” no uid=0 chain.

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.usermount is 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).

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 /mnt on the crafted image β†’ panic at hammer_vnops.c:1728 (guest wedged in DDB).
  • Single-fix #1 (same tree + fix.diff only, kern.version = 6.5-DEVELOPMENT #1: Sun Jul 5 11:11:26 UTC 2026, sha256 = 56bd513b...): identical crafted image, identical ls /mnt β†’ clean return (total 0, LS_RC=0), no panic, guest healthy across 3 runs. stat /mnt succeeds.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Jul 5 11:11:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 56bd513bcb6b124974bb91ba11910ef47d66fe27004bcb49f88aa77dacab6d8d)

Confirmed kernel references

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).