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

hammer2_vop_readdir leaks kernel heap via unvalidated on-disk name_len/namlen β€” OOB read past inode/data buffer into heap

Summary

hammer2_vop_readdir :710-711 INODE branch: ripdata->meta.name_len (uint16 max 65535) passed unbounded to vop_write_dirent d_namlen but ripdata->filename is only 256 bytes (HAMMER2_INODE_MAXNAME). KKASSERT(name_len<MAXNAME) at hammer2_inode.c:1078/1340 validates at CREATE time compiled out production anyway nothing validates at READ time. :723-731 DIRENT branch: bref.embed.dirent.namlen (uint16) passed unbounded dname=hammer2_xop_gdata->buf (64KB buffer). vop_write_dirent bcopy(d_name,dp->d_name,d_namlen) no source-side bound only checks dest fits uio_resid. For INODE name_len=65535: reads 65535 bytes from offset 256 in 1024-byte inode data = ~64KB past into kernel heap via 64KB dio buffer. For DIRENT namlen>64 with data_off==0: chain->data NULL KKASSERT compiled out NULL->buf deref panic. Crafted HAMMER2 image forged CRC mount then unprivileged getdents/readdir triggers. Same pattern as DF-0769. Fix: runtime if(name_len>=HAMMER2_INODE_MAXNAME) break + if(namlen>=MAXNAME) break + NULL check on md.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0777 Β· 12 files
FileTypeDescriptionSize
craft_img.py trigger-source corrupts a hammer2 image: inflates dirent namlen to 65535 + adds fake INODE with name_len=4096 7.1 KB view raw
readdir_leak.c trigger-source getdents-based trigger that hexdumps leaked dirent entries 3.3 KB view raw
build.sh build-script compiles readdir_leak.c 125 B view raw
run.sh run-script runs readdir_leak on mounted hammer2 353 B view raw
run.log run-log unpatched kernel output showing both leaks 3.0 KB view raw
fix_run.log run-log patched kernel output showing no leak 595 B view raw
fix_build.log build-log single-fix kernel build log 1.4 MB ↓ download
fix.diff suggested-fix validates name_len/namlen against HAMMER2_INODE_MAXNAME before vop_write_dirent 2.3 KB view raw
env.txt environment uname, cc version, mount state 292 B view raw
VERDICT.md verdict full analysis and reproduction narrative 3.5 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
VERDICT.md verdict full analysis and reproduction narrative
↓ download raw

DF-0777: hammer2_vop_readdir leaks kernel heap via unvalidated on-disk name_len/namlen

Verdict: REPRODUCED + FIXED

Impact: Info leak (OOB read past directory-entry data into adjacent kernel buffer content). Medium severity. Mount-time parsing threat model (attacker crafts a hammer2 image; admin mounts it).

Mechanism

hammer2_vop_readdir (sys/vfs/hammer2/hammer2_vnops.c:683-776) reads on-disk directory entries via hammer2_xop_collect and passes their name length directly to vop_write_dirent without validation:

INODE branch (lines 702-711)

ripdata = &hammer2_xop_gdata(&xop->head)->ipdata;
r = vop_write_dirent(&error, uio, ..., ripdata->meta.name_len, ripdata->filename);

ripdata->meta.name_len is a uint16_t from on-disk inode data (max 65535). ripdata->filename is a 256-byte (HAMMER2_INODE_MAXNAME) field. vop_write_dirent calls bcopy(d_name, dp->d_name, d_namlen) β€” with name_len > 256, this reads past the filename field into adjacent inode data and the 64KB dio buffer.

DIRENT branch (lines 718-731)

namlen = bref.embed.dirent.namlen;
if (namlen <= sizeof(bref.check.buf)) {
    dname = bref.check.buf;    // 64-byte inline buffer
} else {
    dname = hammer2_xop_gdata(&xop->head)->buf;  // chain data buffer
}
r = vop_write_dirent(&error, uio, ..., namlen, dname);

bref.embed.dirent.namlen is a uint16_t from the on-disk blockref. For namlen > 64 with data_off != 0, dname points to a data block buffer (typically 1024 bytes within a 64KB dio buffer). With namlen = 65535, the bcopy reads 65535 bytes from the buffer, leaking adjacent on-disk content.

The only existing validation (KKASSERT(name_len < HAMMER2_INODE_MAXNAME) at hammer2_inode.c:1078,1340) is at create time and compiled out in production kernels. Nothing validates at read time.

Threat model

An attacker crafts a hammer2 filesystem image with forged CRC (or CHECK_NONE blockrefs). An admin mounts it. Any unprivileged user who does getdents/readdir on the mounted filesystem receives the leaked data in their dirent buffer.

Reproduction

Image crafting (craft_img.py)

  1. Create a valid hammer2 image with newfs_hammer2 + files
  2. Add a long-filename file (>64 chars, so DIRENT has data_off != 0)
  3. Modify the DIRENT's namlen to 65535
  4. Add a fake INODE entry (with visible key) pointing to a crafted inode with name_len = 4096
  5. Set CHECK_NONE on all blockrefs in the path (bypass CRC)
  6. Recompute volume header CRC-32C cascade

Trigger (readdir_leak.c)

Mount the corrupted image, then getdents with a 128KB buffer.

Observed (unpatched kernel #0)

entry 5: d_namlen=65535, reclen=65552 β€” LEAK (DIRENT path, ~64KB leaked)
entry 6: d_namlen=4096, reclen=4120 β€” LEAK (INODE path, ~3840 bytes leaked)

After fix (patched kernel #1)

Only 5 legitimate entries returned.
Kernel logs: "ignoring dirent with bogus namlen 65535"
             "ignoring inode with bogus name_len 4096"
No leak.

Fix

The fix (fix.diff) validates name_len/namlen against HAMMER2_INODE_MAXNAME (256) before calling vop_write_dirent. Entries with bogus lengths are skipped (with a kernel warning). See fix.diff for the git-apply-able diff.

Files

  • craft_img.py β€” image corruption tool
  • readdir_leak.c β€” getdents-based trigger
  • build.sh / run.sh β€” repro scripts
  • run.log β€” unpatched kernel output (leak observed)
  • fix_run.log β€” patched kernel output (no leak)
  • fix_build.log β€” single-fix kernel build log
  • fix.diff β€” the fix
  • env.txt β€” guest environment

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. The PoC leaks on the unpatched #0 baseline (2 corrupted entries with d_namlen=65535 and 4096, leaking adjacent kernel buffer data) and does NOT leak on the single-fix #1 kernel (only 5 legitimate entries returned, corrupted entries skipped with kernel warnings). The fix closes the bug completely.

baseline #0: getdents returned 69808 bytes, 7 entries including d_namlen=65535 (LEAK) and d_namlen=4096 (LEAK). patched #1: getdents returned 136 bytes, 5 entries only, no leak. dmesg confirms: 'ignoring dirent with bogus namlen 65535' + 'ignoring inode with bogus name_len 4096'.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 02:03:21 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

none -- this is a pure info-leak (OOB read past allocation into adjacent kernel buffer content). No write primitive, no escalation chain. The leak exposes on-disk filesystem metadata (adjacent inode data, blockref structures) from the 64KB dio buffer to unprivileged userspace. Threat model: attacker crafts a hammer2 image with forged CRCs; admin mounts it; any unprivileged user doing readdir receives the leaked data. Maximum leak size per entry: ~65535 bytes (DIRENT) or ~4096 bytes (INODE).

Evidence (decisive lines)

BASELINE (unpatched #0): getdents returned 69808 bytes across 7 entries. Entry 5: d_namlen=65535, reclen=65552 -- LEAK DETECTED, 40 non-zero bytes past offset 32 (DIRENT path). Entry 6: d_namlen=4096, reclen=4120 -- LEAK DETECTED, 736 non-zero bytes past offset 32 (INODE path, hexdump shows 'ABCDEFGH...' filename pattern followed by 0xDEAD0xAD blockset data proving read past the 256-byte filename field). PATCHED (#1): getdents returned 136 bytes across 5 entries only -- no corrupted entries. dmesg: 'hammer2_readdir: ignoring dirent with bogus namlen 65535' and 'hammer2_readdir: ignoring inode with bogus name_len 4096'.

PoC changes

Created craft_img.py (Python image corruption tool that sets CHECK_NONE on blockrefs and recomputes volume header CRC-32C cascade to bypass integrity checks), readdir_leak.c (getdents-based trigger), build.sh, run.sh, VERDICT.md, fix.diff, manifest.json. No prior PoC existed in findings/poc/DF-0777/.

Verified recommended fix

Validate name_len/namlen against HAMMER2_INODE_MAXNAME (256) in both the INODE and DIRENT branches of hammer2_vop_readdir before calling vop_write_dirent. Entries with bogus lengths are skipped with a kernel warning (kprintf). The full git-apply-able diff is in findings/poc/DF-0777/fix.diff. Matches the finding markdown's proposal direction (runtime validation of name length at read time).

Verdict

REPRODUCED. hammer2_vop_readdir reads on-disk name_len (INODE branch, line 710) and namlen (DIRENT branch, line 723) without validation and passes them directly to vop_write_dirent, which does bcopy(d_name, dp->d_name, d_namlen). A crafted hammer2 image with forged CRCs (CHECK_NONE blockrefs + recomputed volume header CRC-32C) can set name_len/namlen up to 65535, causing bcopy to read far past the 256-byte filename field (INODE) or the 1024-byte data block (DIRENT) into adjacent kernel buffer content. Confirmed by mounting a crafted image and running getdents: entries with d_namlen=65535 (DIRENT path) and d_namlen=4096 (INODE path) were returned to userspace containing leaked kernel buffer data, consistent across 3 runs. The only existing KKASSERT validation is at create-time (hammer2_inode.c:1078,1340), not read-time, and is compiled out in production.