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

readdir trusts on-media name length as bcopy length -> kernel heap OOB read (info leak + panic)

Summary

hammer2_vop_readdir passes attacker-controlled on-media name-length fields (ripdata->meta.name_len uint16_t and bref.embed.dirent.namlen uint16_t) directly as copy length to vop_write_dirent which does bcopy(src dst len). Neither field validated against size of backing buffer. Crafted HAMMER2 image causes kernel to copy up to 65535 bytes from 256-byte fixed array (filename[]) or ~1KiB data block reading far past end of chain data buffer into adjacent kernel heap. Kernel heap memory disclosure to userspace and deterministic panic when over-read crosses unmapped page boundary. INODE branch filename[256] at offset 0x100 inside 1024-byte inode_data name_len>896 reads off end. DIRENT branch data block radix-sized independent of namlen namlen=0xFFFF from 1KiB block. KKASSERT guards on create/ioctl path not readdir expand to nothing production kernels. readdir backend feeds raw chains no name-length scrubbing.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2562 Β· 11 files
FileTypeDescriptionSize
forge.c trigger-source Image forger: patches DIRENT namlen to 0xFFFF + fixes full XXH64/CRC32C CRC chain 15.1 KB view raw
crc32ctab.h trigger-source CRC32C lookup table for volume header CRC recomputation 3.2 KB view raw
poc.c trigger-source Unprivileged getdents(2) PoC with 128KB buffer 3.2 KB view raw
setup_image.sh trigger-source Creates hammer2 image with >64-byte filename for DIRENT data block 950 B view raw
build.sh build-script Builds forge + poc binaries 246 B view raw
run.sh run-script Full reproduction chain: forge -> mount -> readdir as maxx 947 B view raw
fix.diff suggested-fix Validates name_len/namlen <= HAMMER2_INODE_MAXNAME before vop_write_dirent 1.6 KB view raw
VERDICT.md verdict Full analysis and reproduction narrative 5.8 KB ↓ raw
fix_build.log build-log Full single-fix kernel build output 5.6 MB ↓ download
fix_run.log run-log PoC output on fixed kernel: 136 bytes, no OOB read 657 B view raw
env.txt environment Guest uname and compiler version 247 B view raw
VERDICT.md verdict Full analysis and reproduction narrative
↓ download raw

DF-2562 β€” hammer2 readdir OOB heap read via unchecked on-media name length

Verdict: REPRODUCED (info-leak / OOB read), FIX VALIDATED

The bug is real and confirmed. An unprivileged user calling readdir (getdents) on a hammer2 mount backed by a malicious filesystem image triggers a kernel heap out-of-bounds read of up to ~64 KB past the DIRENT data block. The over-read bytes are copied into a struct dirent and returned to userspace.

Mechanism

hammer2_vop_readdir (sys/vfs/hammer2/hammer2_vnops.c) iterates directory entries via the hammer2 xop machinery. For each chain it extracts the on-media name length and passes it unchecked to vop_write_dirent():

DIRENT branch (the one triggered by this PoC)

// hammer2_vnops.c:723
namlen = bref.embed.dirent.namlen;      // attacker-controlled, uint16
if (namlen <= sizeof(bref.check.buf))   // 64 bytes
    dname = bref.check.buf;
else
    dname = hammer2_xop_gdata(&xop->head)->buf;  // 1024-byte data block
// hammer2_vnops.c:729
r = vop_write_dirent(&error, uio, ..., namlen, dname);

vop_write_dirent (sys/kern/vfs_subr.c:2575):

bcopy(d_name, dp->d_name, d_namlen);   // OOB if d_namlen > source size

With namlen = 0xFFFF (forged on-media), dname points to a 1024-byte DIRENT data block (HAMMER2_ALLOC_MIN), but bcopy copies 65535 bytes β€” reading 64511 bytes past the block into adjacent kernel heap. The result is uiomove'd to the user buffer.

INODE branch (same class of bug, not separately triggered)

// hammer2_vnops.c:710
r = vop_write_dirent(&error, uio, ...,
    ripdata->meta.name_len,    // attacker-controlled, uint16
    ripdata->filename);         // 256-byte fixed array (HAMMER2_INODE_MAXNAME)

name_len > 256 would over-read past filename[] into the inode blockset/data union and beyond the 1024-byte inode_data. (The INODE branch is not reachable via normal readdir because inode chains have keys without HAMMER2_DIRHASH_VISIBLE set; but the code path is identical and vulnerable.)

Reproduction

Image forging

The PoC forges a hammer2 filesystem image:

  1. Create a fresh 64 MB hammer2 image with newfs_hammer2.
  2. Mount, create testdir/, add files including one with a >64-byte name (AAAA...AAAA.txt, 76 bytes) to force a DIRENT with a separate 1024-byte data block (names ≀ 64 bytes embed the name in bref.check.buf).
  3. Unmount.
  4. forge.c patches bref.embed.dirent.namlen from 76 to 65535 in the on-disk DIRENT blockref. Because this changes the parent inode_data, the full CRC chain must be recomputed: - testdir inode_data β†’ INDIRECT block β†’ DATA PFS root β†’ SUPROOT β†’ volume header. - Inode/indirect CRCs are XXH64 (seed 0x4d617474446c6c6e). - Volume header CRCs are CRC32C (3 regions: sect0, sect1, full VH).
  5. Re-mount the forged image.

PoC trigger (unprivileged)

poc.c calls getdents(2) with a 128 KB buffer (must exceed _DIRENT_RECLEN(65535) β‰ˆ 65552). The kernel returns the forged entry's dirent record containing 65535 bytes of d_name β€” 76 bytes of real filename plus 65459 bytes over-read from kernel heap.

Evidence

Unpatched kernel (#0):

getdents returned 65688 (errno=0 ok)

The 65688-byte response includes: - "." (24 B), ".." (24 B), "second_file_padding.txt" (40 B) - forged entry (65552 B): d_namlen=0xFFFF, d_name = 76 B filename + 65476 B OOB-read kernel heap - "AAAABBBB...txt" (48 B)

On this quiet guest the OOB-read bytes are zero (the adjacent buffer-cache pages happen to be zeroed), but the read crossed 64511 bytes past the 1024-byte source buffer β€” the primitive is real.

Fixed kernel (#1):

getdents returned 136 (errno=0 ok)

Only 136 bytes returned (".", "..", and two legitimate entries). The forged entry is rejected. dmesg shows:

hammer2_readdir: ignoring dirent with corrupt namlen 65535

Impact classification

  • Class: kernel heap OOB read β†’ information disclosure (info-leak).
  • Impact ceiling: Up to ~64 KB of kernel heap memory disclosed per readdir call. On a busy system the adjacent pages contain other filesystem data, slab objects, or metadata. Repeated calls can scan large regions of kernel heap.
  • No write primitive: this bug is read-only. Escalation to uid=0 requires a write primitive (OOB write, UAF, etc.) which this bug does not provide. This is a valid Phase-6 hard blocker for escalation.
  • Trigger: unprivileged user readdir on a malicious hammer2 mount. Precondition: an admin has mounted (or made mountable via vfs.usermount) a malicious filesystem image. This is a realistic threat model (USB media, downloaded images).

Fix

fix.diff adds validation in hammer2_vop_readdir:

  • INODE branch: if ripdata->meta.name_len > HAMMER2_INODE_MAXNAME (256), log a warning and skip the entry (release gdata, goto next_entry).
  • DIRENT branch: if bref.embed.dirent.namlen > HAMMER2_INODE_MAXNAME, log a warning and skip the entry.
  • A next_entry: label is added at the bottom of the for-loop for clean skip flow.

The fix is minimal and targeted at the root cause. Validated on a built-and-booted single-fix kernel (#1): the PoC returns 136 bytes (legitimate entries only), no OOB read, no panic, guest stays up.

Files

File Description
forge.c Image forger: patches namlen + fixes XXH64/CRC32C chain
crc32ctab.h CRC32C lookup table for volume header CRC
poc.c Unprivileged getdents trigger with 128 KB buffer
setup_image.sh Creates the hammer2 image with >64-byte filename
build.sh Builds forge + poc
run.sh Full reproduction chain
fix.diff git-apply-able fix for hammer2_vnops.c
fix_build.log Full kernel build output (single-fix kernel)
fix_run.log PoC output on fixed kernel (136 bytes, no OOB)
env.txt Guest environment info

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: the PoC returns 65688 bytes on the unpatched #0 baseline (forged entry with d_namlen=0xFFFF and ~64KB OOB-read kernel heap) and returns only 136 bytes on the single-fix kernel #1 (forged entry rejected, dmesg: 'hammer2_readdir: ignoring dirent with corrupt namlen 65535', no OOB read, no panic, guest stays up). The fix closes the bug.

baseline (#0): getdents returned 65688 β€” forged 65552-byte entry with d_namlen=0xFFFF present, OOB read of 64511 bytes past 1024-byte DIRENT data block. patched (#1): getdents returned 136 β€” forged entry absent, only legitimate entries returned, no OOB.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 09:50:31 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

none (read-only OOB β€” valid Phase-6 hard blocker for escalation). This bug provides a kernel heap OOB READ only (info-leak of up to ~64 KB per readdir call). No write primitive: the bcopy destination is a freshly kmalloc'd dirent buffer immediately copied to userspace and freed. No slab corruption, no function-pointer overwrite, no ucred forge is possible. Escalation to uid=0 requires a write primitive which this bug does not provide. Info-leak ceiling: up to 65535 bytes of kernel buffer-cache/heap memory disclosed per readdir call, repeatable. Precondition: admin mounts a malicious hammer2 image.

Evidence (decisive lines)

Unpatched kernel #0: getdents returned 65688 bytes. Forged entry at buffer offset 0x58: d_fileno=0x0403, d_namlen=0xFFFF, d_type=DT_REG(8), d_name 'AAAA...AAAA.txt' (76 bytes) followed by 65459 bytes of OOB-read kernel heap. Fixed kernel #1: getdents returned 136 bytes β€” forged entry rejected, dmesg shows 'hammer2_readdir: ignoring dirent with corrupt namlen 65535'.

PoC changes

Authored all PoC sources from scratch. forge.c: image forger that patches DIRENT namlen to 0xFFFF AND recomputes the full hammer2 CRC chain (XXH64 for inode/indirect blocks with seed 0x4d617474446c6c6e, CRC32C for volume header β€” 3 regions). crc32ctab.h: generated CRC32C lookup table. poc.c: unprivileged getdents(2) caller with 128KB buffer. setup_image.sh: creates hammer2 image with a >64-byte filename to force a DIRENT with a separate 1024-byte data block. fix.diff: validates name_len/namlen <= HAMMER2_INODE_MAXNAME before vop_write_dirent.

Verified recommended fix

In sys/vfs/hammer2/hammer2_vnops.c hammer2_vop_readdir: add a bounds check rejecting entries with name_len/namlen > HAMMER2_INODE_MAXNAME (256) before calling vop_write_dirent. Both the INODE branch (line 710: ripdata->meta.name_len) and DIRENT branch (line 723: bref.embed.dirent.namlen) are patched with a kprintf warning and goto next_entry skip. Full git-apply-able diff in findings/poc/DF-2562/fix.diff.

Verdict

REPRODUCED. The bug is real: hammer2_vop_readdir (sys/vfs/hammer2/hammer2_vnops.c:706-711 INODE branch, :723-731 DIRENT branch) passes the attacker-controlled on-media name-length fields (ripdata->meta.name_len uint16 and bref.embed.dirent.namlen uint16) directly as the bcopy length to vop_write_dirent (sys/kern/vfs_subr.c:2575). A malicious hammer2 image with namlen forged to 0xFFFF causes bcopy to read 65535 bytes from a 1024-byte DIRENT data block, leaking 64511 bytes of adjacent kernel heap to userspace via getdents. Confirmed on the unpatched #0 kernel: getdents returned 65688 bytes including the forged 65552-byte dirent entry (d_namlen=0xFFFF).