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

Dirent namlen unbounded by chain->bytes: OOB read past 64KB DIO buffer in dirent_test AND 127-byte kernel-memory disclosure to userspace via getdents

Field Value
ID DF-2619
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:H
CWE CWE-125 Out-of-bounds Read
File sys/vfs/hammer2/hammer2_chain.c
Lines 5781-5790
Area vfs
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

For DIRENT chains with names longer than 64 bytes, hammer2_chain_dirent_test() bcmp()s name_len bytes from chain->data->buf, where name_len must equal the on-disk bref.embed.dirent.namlen (uint16, attacker-controlled in a crafted image). Nothing requires namlen <= chain->bytes, so a crafted small-radix dirent block (e.g. 64 bytes) placed at the tail of a 64KB DIO window makes the comparison read up to ~191 bytes past the end of the kernel buffer.

Root cause

chain.c:5781-5785: if (chain->bref.type == HAMMER2_BREF_TYPE_DIRENT && chain->bref.embed.dirent.namlen == name_len) { if (name_len > sizeof(chain->bref.check.buf) && bcmp(chain->data->buf, name, name_len) == 0) β€” chain->data points at the block inside the 64KB dio window (chain.c:1100 via hammer2_io_data, whose offset check hammer2_io.c:565 only validates the block START), and chain->bytes is the radix-derived allocation size (chain.c:189-192) which the crafted image can set smaller than namlen. With data_off = 0xFFC6 (radix 6, block at window offset 0xFFC0) and namlen = 255, bcmp reads 191 bytes beyond bp->b_data+65536. Legitimate images always allocate >= HAMMER2_ALLOC_MIN (1024) for long names.

Threat model & preconditions

  • Attacker position: crafted filesystem image, mounted; unprivileged user calls open() with a 255-byte filename component whose hash lands on the crafted dirent.
  • Privileges gained or impact: overread result is only used as a whole-buffer bcmp equality test (no practical oracle), but the read touches adjacent kernel memory and can fault (panic).
  • Required config or capabilities: mount of crafted image.
  • Reachability: single lookup syscall.

Proof of concept

Build & run

craft image with DIRENT bref {data_off=0x0000FFC6, embed.dirent.namlen=255,
check.buf = 64 filler bytes}; recompute parent check; mount;
open("/mnt/dir/<255-byte-name>")

Expected output

bcmp reads bp->b_data+0xFFC0 .. +0x100BF (191 bytes past the 64KB buffer);
unmapped following page -> kernel fault/panic; otherwise silent.

Impact

Crafted-image-gated OOB read of up to ~191 bytes; possible panic; no practical disclosure oracle.

--- a/sys/vfs/hammer2/hammer2_chain.c
+++ b/sys/vfs/hammer2/hammer2_chain.c
@@ -5781,7 +5781,8 @@ hammer2_chain_dirent_test(hammer2_chain_t *chain, const char *name,
    if (chain->bref.type == HAMMER2_BREF_TYPE_DIRENT &&
        chain->bref.embed.dirent.namlen == name_len) {
        if (name_len > sizeof(chain->bref.check.buf) &&
+           name_len <= chain->bytes &&
            bcmp(chain->data->buf, name, name_len) == 0) {
            return 1;
        }

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Verification (2026-08-29, pass 2)

REPRODUCED on the stock kernel β€” upgraded Low β†’ High. Two faces: 1. Userspace kernel-memory disclosure: getdents(2) on the crafted directory returns 127 bytes past the 64KB DIO buffer inside the dirent name (pointer-like values captured in the run log) β€” any unprivileged user, post-mount. The read side (hammer2_vop_readdir, vnops.c:723-737) copies the unbounded namlen just like the cited dirent_test bcmp. 2. Panic: stat of the 255-char name faults inside hammer2_chain_dirent_test+0x89 / memcpy+0x19 <- hammer2_vop_readdir (fatal trap 12) when the adjacent page is unmapped.

Fix (clamp namlen to chain->bytes in dirent_test; skip over-long entries in readdir) validated on rebuilt kernel #2 with zero regression on legitimate 255-char names. Evidence: findings/poc/DF-2619/ (forge_2619.py, getdents dump with hexdump, panic captures, fix.diff, fix logs).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2619 Β· 22 files
FileTypeDescriptionSize
forge_2619.py β€” 2.8 KB view raw
h2common.py β€” 3.8 KB view raw
getdents_dump.c β€” 1.5 KB view raw
statprobe.c β€” 453 B view raw
run_2619.sh β€” 1011 B view raw
run_2619_instr.sh β€” 688 B view raw
run_stock.log β€” 2.4 KB view raw
console_excerpts.txt β€” 2.9 KB view raw
fix.diff β€” 959 B view raw
fix_run.log β€” 604 B view raw
instr_build.log β€” 1.2 MB ↓ download
instr_build2.log β€” 5.6 MB ↓ download
instr_install.log β€” 80.4 KB view raw
fix_build.log β€” 5.6 MB ↓ download
fix_install.log β€” 80.4 KB view raw
env.txt β€” 505 B view raw
README.md β€” 3.1 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
build.sh β€” 1.2 KB view raw
run.sh β€” 325 B view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 6.9 KB view raw

DF-2619 β€” hammer2 dirent namlen unbounded by chain->bytes (OOB read past 64KB DIO buffer)

What this pack contains

file what
h2common.py + forge_2619.py image forger (volhdr→sroot→PFS walk, DF-2616 technique)
getdents_dump.c getdents(2) dumper that hexdumps every d_name (leak capture)
statprobe.c stat(2) probe printing errno
run_2619.sh / run_2619_instr.sh guest trigger scripts (stock / instrumented kernel)
run_stock.log the leak: full getdents output on stock kernel #0, forged vs base
console_excerpts.txt panic texts (stock memcpy+0x19; instr. dirent_test+0x89 cmpb; instr. backtrace hammer2_vop_readdir+0x5c8)
fix.diff the fix (dirent_test clamp + readdir skip)
fix_run.log fix validation on kernel #2
instr_build*.log, fix_build.log, fix_install.log, env.txt full untrimmed build/env logs

Build

  1. Guest (root): base image per run_2619.sh prerequisites β€” newfs_hammer2 -L testvol on a 64M vn image, create ONE file with a 255-char name (head -c 255 /dev/zero | tr '\0' 'A'), sync, umount, pull base2619.img to the host.
  2. Host: python3 forge_2619.py base2619.img craft2619.img (relocates the file's DIRENT bref to 0x210FF87 = window 0x2100000, offset 0xFF80, radix 7 β†’ chain->bytes=128 < namlen=255; writes the first 128 name bytes into the block; sets CHECK_NONE on the dirent, PFS-inode and sroot brefs; recomputes the volhdr CRC32Cs).
  3. Guest: cc -O -o getdents_dump getdents_dump.c && cc -O -o statprobe statprobe.c, push craft2619.img.

Run

vnconfig -c vn0 craft2619.img
mount -o ro -t hammer2 /dev/vn0@testvol /mnt/h2x
./getdents_dump /mnt/h2x          # leak (or panic, layout-dependent)
./statprobe "/mnt/h2x/$(head -c 255 /dev/zero | tr '\0' 'A')"   # OOB compare
umount /mnt/h2x; vnconfig -u vn0

Expected

  • Stock kernel, adjacent page mapped (observed): the 255-char dirent name returned by getdents ends with 127 bytes of kernel memory from past the end of the 64KB DIO buffer (see run_stock.log hexdump: ...4141 Γ—128 then 01 00 … 6f54f378235a0600 …).
  • Stock kernel, adjacent page unmapped (observed): fatal trap 12 in memcpy+0x19 (see console_excerpts.txt [A]).
  • Control (base2619.img): everything normal, stat succeeds.
  • Fixed kernel #2: malformed entry skipped β€” no garbage tail, no panic, stat β†’ ENOENT; legit long-name dirents on the base image still list and stat fine (fix_run.log).

Trigger privilege: mounting the crafted image requires root (or vfs.usermount=1 with a user-owned vn device); the leak itself (getdents on a world-readable directory of the mounted fs) is available to any unprivileged user afterwards, exactly like prior art DF-2627.

VERDICT.md
↓ download raw

DF-2619 VERDICT

Status: reproduced (impact: kernel memory disclosure to userspace via getdents; panic variant also reproduced). Confidence: certain.

Root cause (line-precise)

hammer2_chain_dirent_test() compares a DIRENT chain's on-media name against a lookup key using the attacker-controlled on-disk bref.embed.dirent.namlen with no bound against the chain's actual data block size (chain->bytes, derived from data_off's radix):

sys/vfs/hammer2/hammer2_chain.c:5781-5790
    if (chain->bref.type == HAMMER2_BREF_TYPE_DIRENT &&
        chain->bref.embed.dirent.namlen == name_len) {
        if (name_len > sizeof(chain->bref.check.buf) &&        /* >64 */
            bcmp(chain->data->buf, name, name_len) == 0) {     /* unbounded */

chain->data points into a 64KB DIO buffer at data_off & ~HAMMER2_OFF_MASK_RADIX (chain.c:1100 chain->data = (void *)bdata ← hammer2_io_data() io.c:555-563). Nothing validates namlen <= chain->bytes:

  • chain load (chain.c:920-1105) only checks the window-fit invariant KKASSERT((lbase+lsize-1) & pmask) == pbase (io.c:127) β€” a radix-7 (128-byte) block at window offset 0xFF80 passes (0xFF80+0x7F = 0xFFFF stays inside the 64KB window) while namlen=255 walks 127 bytes past both the block and (because the block ends exactly at the buffer end) past the DIO buffer itself.

The same unbounded namlen has a second, worse consumer β€” hammer2_vop_readdir():

sys/vfs/hammer2/hammer2_vnops.c:723-737
    namlen = bref.embed.dirent.namlen;                    /* on-disk, 255 */
    if (namlen <= sizeof(bref.check.buf)) ...
    else { dname = hammer2_xop_gdata(&xop->head)->buf; }  /* DIO+0xFF80 */
    r = vop_write_dirent(&error, uio, ..., namlen, dname); /* -> userspace */

PoC

forge_2619.py (DF-2616 prior-art technique): base image newfs_hammer2 -L testvol + one 255-char-named file; its DIRENT bref (in the PFS root blockset, key = dirhash(name), namlen 255) is relocated to data_off = 0x210FF87 (window 0x2100000, offset 0xFF80, radix 7); the first 128 bytes of the name are written at 0x210FF80 so the compare walks the whole in-buffer range and continues past the buffer; ancestors (dirent bref, PFS inode bref, sroot bref) set to CHECK_NONE (methods=0) and the three volhdr CRC32Cs recomputed, so mount succeeds normally.

What was observed (all on the provided QEMU guest)

  1. Leak, stock INVARIANTS kernel #0 (run_stock.log): getdents on the forged mount returns the 255-byte dirent name whose last 127 bytes are the kernel memory located directly after the 64KB DIO buffer β€” captured bytes include pointer-like 6f 54 f3 78 23 5a 06 00 (LE 0x00065a2378f3546f, repeated), c0 01 00 00 (448), 02 00 00 00 β€” adjacent buffer-header/cache content. Control image: clean 255Γ—'A' name, stat succeeds.
  2. Panic, stock kernel #0 (console_excerpts.txt [A]): same run family, different heap state β€” Fatal trap 12, supervisor read, page not present at memcpy+0x19 (RIP 0xffffffff80bcaba9 = memcpy, verified via nm), fault VA page-granular = first page after the DIO buffer. The read is the dirent name copy.
  3. Panic inside the cited compare, instrumented kernel #1 (console_excerpts.txt [C]): stat() of the 255-char name faults at hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1) β€” the per-byte name compare itself reading past the buffer (the instrumented loop is semantically identical to the stock bcmp).
  4. Full backtrace, instrumented kernel #1 (console_excerpts.txt [D]): memcpy+0x19 ← hammer2_vop_readdir+0x5c8 β€” the vnops.c:729 vop_write_dirent copy of namlen bytes from chain->data->buf.

Threat model: mounting the crafted image needs root (or vfs.usermount=1 with an owned device β€” same precondition as all hammer2-image findings, incl. prior art DF-2616/17/18/20/27); the getdents disclosure itself is then available to any user with read access to the directory. Real-world trigger without a crafted image: on-media corruption of a hammer2 directory entry (namlen β‰₯ block size is accepted by the reader). Escalation: read-only primitive; 127 bytes of buffer-cache-adjacent KVA per call β€” KASLR/heap-layout aid, no write.

Fix

fix.diff (two hunks, both consumers):

  1. chain.c: only run the data-block bcmp when name_len <= (size_t)chain->bytes (malformed entry never matches).
  2. vnops.c readdir: skip the entry when namlen > 1 << (bref.data_off & HAMMER2_OFF_MASK_RADIX) (the radix is available in the frontend's copy of the bref).

A load-time rejection (chain->error = HAMMER2_ERROR_CHECK in hammer2_chain_load_data) was considered and rejected: the readdir xop feeds chains without checking chain->error and hammer2_xop_gdata() would then run on a NULL chain->data, trading the OOB read for a NULL-deref panic.

Fix validation (kernel #2, fix_run.log)

  • Forged image: getdents returns cleanly (rc=0), the malformed 255-A entry is absent (no garbage tail), stat(AΓ—255) β†’ ENOENT, healthy entry longname_marker_check still lists (ino 1024) and stats.
  • Control base image: unchanged β€” 255-A entry lists, stat succeeds (ino 1025) β†’ no regression on legitimate long-name dirents.
  • No panics during the whole validation run.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (dirent_test clamp + readdir skip) applied to guest /usr/src, kernel #2 built (fix_build.log, untrimmed) and installed. Exact PoC re-run: forged image getdents returns rc=0 with the malformed 255-A entry ABSENT (no garbage tail, no kernel bytes), statprobe on the 255-char name returns ENOENT, healthy entry longname_marker_check still lists (ino 1024) and stats; base-image control unchanged (255-char entry lists, stat succeeds ino 1025, proving no regression on legitimate long-name dirents). No panics during validation.

['fix.diff', 'fix_build.log / fix_install.log (untrimmed)', 'fix_run.log', 'console_excerpts.txt [A]/[D] vs fix_run.log comparison']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Sat Aug 29 00:17:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root (or vfs.usermount=1 + owned vn) mounts crafted image -> forged DIRENT (key=dirhash(name), namlen=255) with data_off=WIN|0xFF80|7 -> chain load succeeds (radix 7 block fits the 64KB window per io.c:127 KKASSERT; CHECK_NONE ancestors per DF-2616 technique; volhdr CRCs recomputed) -> any getdents(2) on the directory returns 127 bytes of kernel memory past the DIO buffer in the dirent name (unprivileged read of a world-readable dir suffices); stat(2) of the long name drives the same unbounded compare in dirent_test. Read-only primitive: KASLR/heap-layout aid (adjacent buffer-header/pointer bytes); no write primitive. Memory-layout dependent panic face: fatal trap 12 when the page after the buffer is unmapped.

Evidence (decisive lines)

['run_stock.log - full untrimmed stock-kernel getdents dump: forged image name hexdump ends 4141...(128) then 01 00 ... 6f54f378235a0600 ... (kernel bytes past the 64KB DIO buffer); base-image control clean', 'console_excerpts.txt [A] - stock panic: Fatal trap 12, memcpy+0x19, fault VA 0xfffff8005f926000 (page-granular, = page after the DIO buffer)', 'console_excerpts.txt [C] - instrumented kernel: Stopped at hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1) (the cited compare reading past the buffer)', 'console_excerpts.txt [D] - full panic backtrace memcpy+0x19 <- hammer2_vop_readdir+0x5c8', 'forge_2619.py output - DIRENT bref 0x1c0000a -> 0x210ff87 (radix 7, 128B block at window tail)', 'fix_run.log - kernel #2: forged image lists cleanly with the malformed entry absent, stat -> ENOENT, marker entry intact; base control 255-char name still lists+stats']

PoC changes

Seed sketch's geometry was wrong twice over: (1) it suggested radix 8 (256B) which is >= namlen 255 (no OOB of chain->bytes) and (2) it ignored that a window-CROSSING block trips the io.c:127 KKASSERT. Working geometry: radix 7 (chain->bytes=128 < 255) at in-window offset 0xFF80 so the block ends exactly at the 64KB buffer end and the 255-byte compare runs 127 bytes past the buffer. Also found and used the far better-exposed consumer hammer2_vop_readdir (vnops.c:723-737) which returns the OOB bytes to userspace, where the filed path (dirent_test bcmp) alone is silent unless it faults. DragonFly dirent layout fixed on the fly (no d_reclen; getdents(int,char*,size_t) declared manually; stat -t found to always exit 0 so a C statprobe was written).

Verified recommended fix

Bound both consumers of on-disk namlen: in hammer2_chain_dirent_test require name_len <= chain->bytes for the data-block bcmp, and in hammer2_vop_readdir skip entries whose namlen exceeds 1 << (bref.data_off & HAMMER2_OFF_MASK_RADIX).

Verdict

CONFIRMED and reproduced two ways on the stock INVARIANTS kernel, with the impact ceiling well above the Low/OOB-read filing. hammer2_chain_dirent_test() (chain.c:5781-5790) bcmp's name_len bytes from chain->data->buf using the on-disk namlen with no bound against chain->bytes, and hammer2_vop_readdir() (vnops.c:723-737) copies the same unbounded namlen from chain->data->buf straight into the getdents(2) result. A forged radix-7 (128B) dirent block at the tail of a 64KB window (0x210FF87; passes the only geometry check, the window-fit KKASSERT at io.c:127) with namlen=255 makes both consumers read 127 bytes past the end of the 64KB DIO buffer. Observed: (a) mapped-adjacent case - getdents returned the 255-byte name whose last 127 bytes are adjacent kernel memory (pointer-like 0x00065a2378f3546f values, 0x1c0/2 fields) - a userspace-visible kernel heap disclosure for any user able to read the directory after the (root/vfs.usermount) mount; (b) unmapped case - fatal trap 12 at memcpy+0x19 (stock, RIP verified = memcpy) and, with debug.debugger_on_panic=0, full backtrace memcpy+0x19 <- hammer2_vop_readdir+0x5c8, plus a separate fault INSIDE the dirent_test compare itself (hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1)) via stat(2) of the long name. Controls: base image lists/stats normally. Fix.diff clamps the dirent_test compare to chain->bytes and skips readdir entries whose namlen exceeds their radix-derived block size; kernel #2 re-run: malformed entry skipped (no leak bytes, no panic, stat ENOENT) with zero regression on legitimate 255-char names (base control still lists and stats).