Heap OOB read when parsing crafted directory entries near buffer tail β missing d_reclen >= DIRSIZ validation
Summary
ufs_dirhash.c:201-202 ufsdirhash_build: d_reclen==0||d_reclen>DIRBLKSIZ-(pos&(DIRBLKSIZ-1)) β only checks fits in 512-byte chunk NOT d_reclen>=DIRSIZ(0,ep) (min to hold own name). Crafted image: entry at chunk offset 504 d_reclen=8 d_namlen=255 d_ino=1. ep->d_name at chunk offset 512 = buffer end. ufsdirhash_hash :209 fnv_32_buf(ep->d_name,ep->d_namlen,...) reads 255 bytes past buffer into kernel heap OOB read. Same missing check at lookup :370-378 findfree :468-501 getprev :914-916 checkblock :746-749. ufs_lookup.c has ufs_dirbadentry but dirchk defaults 0 dirhash never calls. Trigger: crafted UFS image mount then ls crafted_dir. Fix: add d_reclen>=DIRSIZ check for d_ino!=0 entries.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0844 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dirhash_oob.c | trigger-source | Userspace harness: replicates dirhash build loop with guard page, deterministically proves OOB read | 8.4 KB | view raw |
| dirhash_patch.c | trigger-source | UFS image patcher: creates malformed dir entry (d_reclen=8, d_namlen=255) + trailing free entry | 5.5 KB | view raw |
| dirhash_corrupt.c | trigger-source | Earlier image corruptor (superseded by dirhash_patch.c) | 5.2 KB | view raw |
| image_trigger.sh | trigger-script | Shell script for full image workflow (reference) | 3.8 KB | view raw |
| build.sh | build-script | Build commands for harness and corruptor | 273 B | view raw |
| run.sh | run-script | Run commands for harness | 276 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: add d_reclen >= DIRSIZ(NEWDIRFMT, ep) check in ufsdirhash_build | 776 B | view raw |
| fix_build.log | build-log | Single-fix kernel build output (rc=0) | 5.6 MB | β download |
| run_harness_patched.log | run-log | Harness output on patched kernel | 1.3 KB | view raw |
| env.txt | environment | Guest uname, cc version, sysctls | 361 B | view raw |
| VERDICT.md | verdict | Full analysis: mechanism, evidence, fix validation | 7.0 KB | β raw |
| README.md | readme | human reproduce doc | 2.0 KB | β raw |
| build.log | build-log | kernel build log excerpt proving -Werror clean compile of patched source | 219 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 |
DF-0844 β Heap OOB read in UFS dirhash (missing d_reclen >= DIRSIZ check)
Summary
ufsdirhash_build() in sys/vfs/ufs/ufs_dirhash.c validates that each directory
entry's d_reclen fits within its 512-byte DIRBLKSIZ chunk (line 201-202), but
does not validate that d_reclen is large enough to hold the entry's own name
(d_reclen >= DIRSIZ(NEWDIRFMT, ep)). A crafted UFS image with a truncated final
entry (d_reclen=8, d_namlen=255) passes the existing check but causes
ufsdirhash_hash() to read 255 bytes from ep->d_name β extending 247 bytes past
the entry boundary into adjacent kernel heap (CWE-125).
The check already exists in ufs_dirbadentry() (ufs_lookup.c:636) but is gated
behind dirchk=0 (default off) and never called by dirhash.
Build
./build.sh
Produces dirhash_oob (userspace harness) and dirhash_corrupt (image corruptor).
Run
Userspace harness (deterministic OOB proof)
./dirhash_oob # Buggy mode: SIGSEGV at guard page = OOB confirmed
./dirhash_oob --fixed # Fixed mode: entry rejected, no OOB
Image-based trigger (live kernel)
Requires root on the guest to mount. See image_trigger.sh for the full workflow,
or use dirhash_patch.c to create a malformed UFS image:
# Create a UFS image with 25 files in testdir (dir size = 6656 bytes)
# (see image_trigger.sh for vnconfig/newfs/mount commands)
# Patch the last directory entry to be malformed
./dirhash_patch /tmp/df844.img $((1033 * 512))
# Mount and trigger dirhash
mount -o ro /dev/vn0 /mnt
ls -f /mnt/testdir/
sysctl -n vfs.ufs.dirhash_mem # increases on unpatched, unchanged on patched
Expected results
| Kernel | dirhash_mem | Harness |
|---|---|---|
| Unpatched (#0) | 9685 β 14177 (dirhash built with malformed entry) | OOB READ CONFIRMED |
| Patched (#1) | 9685 β 9685 (entry rejected, no dirhash) | Entry REJECTED |
Fix
See fix.diff: adds d_reclen >= DIRSIZ(NEWDIRFMT, ep) check before hashing.
DF-0844 β Verdict
Verdict: REPRODUCED (heap OOB read, CWE-125). Fix VALIDATED.
Finding: Heap OOB read when parsing crafted directory entries near buffer tail β missing d_reclen >= DIRSIZ validation in ufsdirhash_build().
Impact: Heap out-of-bounds read (up to 255 bytes) from kernel buffer cache memory. The OOB data is fed to the FNV hash function (internal to dirhash) and is not directly returned to userspace. Realistic impact ceiling: silent hash corruption or, if the OOB crosses a page boundary into unmapped memory, kernel panic (DoS). Not a direct info leak.
Severity assessment: Medium is appropriate. The bug is a genuine heap OOB read triggered by a crafted filesystem image (root-mount threat model), but the read data goes into an internal hash, not to userspace.
Mechanism (line-by-line)
The vulnerable code: sys/vfs/ufs/ufs_dirhash.c:199-216
ep = (struct direct *)((char *)bp->b_data + (pos & bmask));
if (ep->d_reclen == 0 || ep->d_reclen >
DIRBLKSIZ - (pos & (DIRBLKSIZ - 1))) { // β only checks fits in 512-byte chunk
brelse(bp); // β does NOT check d_reclen >= DIRSIZ
goto fail;
}
if (ep->d_ino != 0) {
slot = ufsdirhash_hash(dh, ep->d_name, ep->d_namlen); // β reads d_namlen bytes
... // from d_name, OOB if
} // d_reclen < DIRSIZ
The existing check at ufs_dirhash.c:201-202 validates that d_reclen fits within the current 512-byte DIRBLKSIZ chunk. It does not validate that d_reclen is large enough to hold the entry's own name (d_reclen >= DIRSIZ(NEWDIRFMT, ep)).
The missing check (already exists in ufs_dirbadentry)
sys/vfs/ufs/ufs_lookup.c:634-636 has exactly the check that dirhash lacks:
if ((ep->d_reclen & 0x3) != 0 ||
ep->d_reclen > DIRBLKSIZ - (entryoffsetinblock & (DIRBLKSIZ - 1)) ||
ep->d_reclen < DIRSIZ(OFSFMT(dp), ep) || namlen > MAXNAMLEN) {
But ufs_dirbadentry is only called when dirchk is non-zero (ufs_lookup.c:65-67: int dirchk = 0;), and the dirhash code never calls it.
OOB geometry
When a crafted entry has d_reclen=8 (passes chunk check when at chunk offset β₯ 504) but d_namlen=255:
- DIRSIZ(0, ep) = DIRECTSIZ(255) = (8 + 256 + 3) & ~3 = 264
- d_reclen(8) < DIRSIZ(264) β malformed, but accepted by the existing check
- ufsdirhash_hash(dh, ep->d_name, ep->d_namlen) calls fnv_32_buf(ep->d_name, 255, ...)
- This reads 255 bytes from ep->d_name, extending 247 bytes past the entry's d_reclen boundary into adjacent data or past the kernel buffer tail
Trigger path (root-mount threat model)
- Attacker crafts a UFS filesystem image with a directory β₯ 2560 bytes (to trigger dirhash:
ufs_mindirhashsize = DIRBLKSIZ * 5) - The directory's last entry is malformed:
d_reclen=8,d_namlen=255,d_ino!=0 - A trailing free entry (
d_ino=0) covers the rest of the chunk so the loop ends cleanly - Admin mounts the image (
mount /dev/vn0 /mnt) - Unprivileged user triggers dirhash build via
readdir()orstat()on the directory ufsdirhash_build()iterates entries, accepts the malformed one, andufsdirhash_hash()reads 255 bytes past the entry boundary
Reproduction evidence
1. Userspace harness (deterministic proof of OOB mechanism)
dirhash_oob.c replicates the dirhash build loop with a crafted entry placed at the exact tail of a page, followed by a PROT_NONE guard page. When fnv_32_buf reads ep->d_name (255 bytes), it faults into the guard page:
RESULT: *** OOB READ CONFIRMED ***
fnv_32_buf(ep->d_name, 255) read past the buffer into the guard page.
The missing d_reclen >= DIRSIZ(ep) check allowed a 255-byte
out-of-bounds read from the entry's d_name field.
With --fixed flag (simulating the fix): entry correctly rejected, no OOB read.
2. Image-based PoC (live kernel reachability)
A crafted UFS image (dirhash_patch.c) with a malformed last entry was mounted on the unpatched #0 kernel:
dirhash_mem BEFORE trigger = 9685 dirhash_mem AFTER readdir = 14177 β INCREASED (dirhash built with malformed entry)
The dirhash build SUCCEEDED with the malformed entry, proving the missing check allows acceptance. ufsdirhash_hash was called with the corrupted d_namlen=255, performing the OOB read. Guest remained alive (silent OOB, no panic).
Exploit chain
This is an OOB read, not a write primitive. No escalation to uid=0 is possible β the read data goes into the internal FNV hash, not to userspace. The realistic impact ceiling is:
1. Silent hash corruption β the dirhash produces wrong results, causing directory lookup failures or incorrect behavior (not a security impact per se)
2. DoS via panic β if the OOB read crosses a page boundary into unmapped memory, the kernel panics. This requires specific slab layout alignment (unlikely but possible)
3. No info leak β the OOB bytes are hashed and never returned to userspace
Fix
fix.diff
Adds d_reclen >= DIRSIZ(NEWDIRFMT, ep) check for entries with d_ino != 0 in ufsdirhash_build(), right before the ufsdirhash_hash call:
if (ep->d_ino != 0) {
if (ep->d_reclen < DIRSIZ(NEWDIRFMT, ep)) {
/* Corrupted directory. */
brelse(bp);
goto fail;
}
slot = ufsdirhash_hash(dh, ep->d_name, ep->d_namlen);
This matches the existing check in ufs_dirbadentry (ufs_lookup.c:636).
Fix validation
Built single-fix kernel (6.5-DEVELOPMENT #1, sha256 8ddcb6a0...), booted, re-ran the same crafted image:
PATCHED (#1): dirhash_mem BEFORE trigger = 9685 dirhash_mem AFTER readdir = 9685 β UNCHANGED (dirhash NOT built) dirhash_mem AFTER lookup = 9685 β UNCHANGED
The malformed entry is now rejected by the new check β ufsdirhash_build fails β dirhash is NOT created β ufsdirhash_hash is NEVER called β no OOB read. Directory lookups fall back to linear scan (correct behavior for corrupted directories).
Before/after contrast:
- Unpatched (#0): dirhash_mem 9685 β 14177 (malformed entry accepted, OOB read happened)
- Patched (#1): dirhash_mem 9685 β 9685 (malformed entry rejected, no OOB read)
Fix status: FIXED.
PoC files
| File | Type | Description |
|---|---|---|
dirhash_oob.c |
trigger-source | Userspace harness: guard-page OOB proof |
dirhash_patch.c |
trigger-source | UFS image patcher: creates malformed dir entry |
dirhash_corrupt.c |
trigger-source | Earlier image corruptor (superseded by dirhash_patch.c) |
image_trigger.sh |
trigger-script | Shell script for full image workflow (reference) |
build.sh |
build-script | Build commands |
run.sh |
run-script | Run commands |
fix.diff |
suggested-fix | git-apply-able fix adding DIRSIZ check |
fix_build.log |
build-log | Single-fix kernel build output |
run_harness_patched.log |
run-log | Harness output on patched kernel |
env.txt |
environment | Guest environment |
Fix verification
fixedVALIDATED the fix: the crafted UFS image (malformed entry d_reclen=8/d_namlen=255) caused dirhash_mem to increase 9685->14177 on the unpatched #0 baseline (dirhash built, ufsdirhash_hash called with OOB namlen). On the single-fix #1 kernel, the same image caused dirhash_mem to stay at 9685 (dirhash NOT built -- the new d_reclen >= DIRSIZ check rejected the malformed entry before hashing, no OOB read). Fix closes the bug.
baseline #0: BEFORE=9685 AFTER_LOOKUP=14177 (dirhash built with malformed entry -> OOB read happened). patched #1: BEFORE=9685 AFTER_READDIR=9685 AFTER_LOOKUP=9685 (dirhash NOT built -> entry rejected -> no OOB read).
Confirmed kernel references
Detail
Exploit chain
This is an OOB READ (CWE-125), not a write primitive. No escalation to uid=0 is possible: the read data is fed into the internal FNV hash function (ufsdirhash_hash) and never returned to userspace. The realistic impact ceiling is: (1) silent dirhash corruption causing incorrect directory lookups, or (2) DoS via panic if the 255-byte read crosses a page boundary into unmapped memory (unlikely but geometry-dependent). No info leak to userspace. For non-corruption classes, no chain is needed -- impact characterized and documented.
Evidence (decisive lines)
Harness (buggy mode): 'RESULT: *** OOB READ CONFIRMED *** fnv_32_buf(ep->d_name, 255) read past the buffer into the guard page.' Harness (--fixed): 'Entry REJECTED by bounds check.' Image PoC unpatched #0: dirhash_mem 9685->14177 (malformed entry accepted). Image PoC patched #1: dirhash_mem 9685->9685 (malformed entry rejected). Crafted entry: d_reclen=8, d_namlen=255, DIRSIZ(255)=264 > d_reclen(8) -> MALFORMED.
PoC changes
Created PoC evidence pack from scratch (no prior poc_results or poc folder existed). Wrote: dirhash_oob.c (userspace harness replicating dirhash build loop with guard-page OOB detection), dirhash_patch.c (UFS image patcher that creates malformed entry d_reclen=8/d_namlen=255 + trailing free entry for clean loop termination), dirhash_corrupt.c (earlier corruptor, superseded), build.sh, run.sh, baseline_run.sh, image_trigger.sh. All sources, logs, VERDICT.md, manifest.json, fix.diff saved to findings/poc/DF-0844/.
Verified recommended fix
Add d_reclen >= DIRSIZ(NEWDIRFMT, ep) check inside the 'if (ep->d_ino != 0)' block in ufsdirhash_build() at sys/vfs/ufs/ufs_dirhash.c:207, before the ufsdirhash_hash call at line 209. This matches the existing check in ufs_dirbadentry() (ufs_lookup.c:636). If the check fails, treat as corrupted directory (brelse + goto fail). Matches finding proposal (the finding also recommended 'add d_reclen >= DIRSIZ check for d_ino!=0 entries'). The full git-apply-able diff lives in findings/poc/DF-0844/fix.diff.
Verdict
REPRODUCED. The bug is real: ufsdirhash_build() at sys/vfs/ufs/ufs_dirhash.c:201-202 validates d_reclen fits within the 512-byte DIRBLKSIZ chunk but does NOT validate d_reclen >= DIRSIZ(NEWDIRFMT, ep) (the minimum to hold the entry's own name). A crafted UFS image with a truncated final entry (d_reclen=8, d_namlen=255) passes the existing check but causes ufsdirhash_hash() at line 209 to call fnv_32_buf(ep->d_name, 255) which reads 255 bytes from d_name, extending 247 bytes past the entry boundary into adjacent kernel heap -- a CWE-125 OOB read. The check already exists in ufs_dirbadentry() (ufs_lookup.c:636: d_reclen < DIRSIZ(OFSFMT(dp), ep)) but dirchk defaults to 0 and dirhash never calls it. Confirmed by: (1) userspace harness with guard page -> deterministic SIGSEGV proving the OOB mechanism, (2) image-based PoC on live unpatched kernel -> dirhash_mem increased 9685->14177 (malformed entry accepted, dirhash built, ufsdirhash_hash called with corrupted namlen). The OOB data goes into the internal FNV hash, not to userspace -- impact is silent hash corruption or potential DoS via panic if OOB crosses a page boundary.
No comments yet.