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)
PoC verification
Evidence pack
findings/poc/DF-2562 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
- Create a fresh 64 MB hammer2 image with
newfs_hammer2. - 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 inbref.check.buf). - Unmount.
- forge.c patches
bref.embed.dirent.namlenfrom 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 (seed0x4d617474446c6c6e). - Volume header CRCs are CRC32C (3 regions: sect0, sect1, full VH). - 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=0requires 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
readdiron a malicious hammer2 mount. Precondition: an admin has mounted (or made mountable viavfs.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
fixedVALIDATED: 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.
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).
No comments yet.