dirfs_readdir for-loop increment uses already-advanced dp β heap OOB read and memory disclosure
Summary
dirfs_vnops.c:1277-1286 for loop body sets dp=dpn BEFORE increment expression. increment bytes-=_DIRENT_DIRSIZ(dp) uses NEW dp (next unprocessed entry) not just-processed. After last valid entry dp advanced past valid data increment derefs dp->d_namlen OOB read. If bytes stays positive next iteration processes garbage/OOB entry vop_write_dirent bcopy(d_name...,d_namlen) copies heap memory from past buffer to guest user readdir result = kernel heap info leak. Trigger: ~200+ directory entries fill getdirentries buffer completely then loop OOB reads adjacent heap. Fix: remove dp=dpn from loop body let increment handle advancement.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0807 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | Deterministic loop transcription with guard-page + leak-zone allocators | 21.6 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o harness harness.c | 193 B | view raw |
| run.sh | run-script | ./harness | 127 B | view raw |
| build.log | build-log | Full clean compiler output | 102 B | view raw |
| run.log | run-log | Full decisive harness run, all 4 buffer sizes | 4.0 KB | view raw |
| fix.diff | suggested-fix | git-apply-able 1-line fix: remove `dp = dpn` from loop body | 288 B | view raw |
| phase8_validate.sh | fix-validation | Phase 8 compile-neutrality validation | 5.0 KB | view raw |
| fix_run.log | fix-log | Full Phase 8 validation output | 4.0 KB | view raw |
| env.txt | environment | uname, cc version, dirfs absence confirmation | 289 B | view raw |
| VERDICT.md | verdict | Full narrative: reproduced? mechanism? impact ceiling? fix? | 10.1 KB | β raw |
| manifest.json | manifest | This catalog | 2.6 KB | 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-0807 β dirfs_readdir premature dp advancement β heap OOB read + info leak
Verdict: REPRODUCED (deterministic harness; dirfs is vkernel-only so no live-boot test is possible β same precedent as DF-0806).
Impact: kernel heap info leak (read-only primitive). No escalation path β the bug only reads OOB; there is no write, UAF, or type-confusion component.
Confidence: certain.
The bug (line-by-line)
sys/vfs/dirfs/dirfs_vnops.c:1277-1286 β dirfs_readdir for-loop:
for (dp = (struct dirent *)buf; bytes > 0 && uio->uio_resid > 0;
bytes -= _DIRENT_DIRSIZ(dp), dp = dpn) { /* line 1278 */
r = vop_write_dirent(&error, uio, dp->d_ino, dp->d_type,
dp->d_namlen, dp->d_name); /* line 1279-1280 */
if (error || r)
break;
dpn = _DIRENT_NEXT(dp); /* line 1283 */
dp = dpn; /* line 1284 β BUG */
cnt++;
}
The C comma operator in the for-increment (line 1278) evaluates left-to-right:
bytes -= _DIRENT_DIRSIZ(dp)β uses the currentdpdp = dpnβ then advances
But the loop body already executed dp = dpn on line 1284. So when the
increment's step (1) runs, dp is already the next entry (the one the
body has not yet processed), not the entry that was just written out. After
the last valid entry, the body sets dp = dpn = buf + bytes, which (when
the getdirentries buffer was filled completely, the common case with
bufsiz clamped to 4096 at lines 1248-1249 and ~200+ directory entries) is
also one byte past the kmalloc(bufsiz) allocation. Step (1) then derefs
dp->d_namlen past the allocation β heap OOB read.
Worse: if the (untrusted, OOB) d_namlen read is small enough that
DIRENT_DIRSIZ(dp) < bytes, bytes stays positive and the loop body runs
one more iteration with the OOB dp. vop_write_dirent
(sys/kern/vfs_subr.c:2559-2582) then reads dp->d_ino, dp->d_type,
dp->d_namlen, and dp->d_name (line 2575: bcopy(d_name, dp->d_name,
d_namlen)) from past the buffer and copies them into a fresh dirent that is
uiomove'd to the user readdir result. That is a kernel heap info
leak: attacker-recognizable bytes from the slab chunk / redzone adjacent to
the dirfs readdir buffer surface in userland.
Why a harness (not a live-boot test)
dirfs is vkernel64-only: grep -c dirfs sys/conf/files β 0; it is
listed only in sys/platform/vkernel64/conf/files (lines 45-47). It is
absent from the running X86_64_GENERIC host kernel, and there is no
dirfs.ko in /boot/kernel (kldstat -v | grep -c dirfs β 0). It cannot
be mounted or triggered on this guest. The deterministic harness is the
accepted proof β same precedent as DF-0806 (dirfs_readlink off-by-one).
Reproduction: deterministic harness
harness.c transcribes the exact dirfs_readdir for-loop with two
allocator styles, exercising 4 buffer sizes (512 / 1024 / 2048 / 4096 bytes,
the last matching the dirfs_readdir 4096 clamp at lines 1248-1249). Each
buffer is filled with N valid dirents sized so the buffer is exactly
filled (bytes == bufsiz), forcing the premature dp = dpn to advance
past the allocation after the last valid entry.
Guard-page variant (OOB READ)
The buffer is placed flush against a PROT_NONE guard page. After the last
valid entry, the for-increment reads dp->d_namlen from the guard page β
SIGSEGV (caught via sigaction + siglongjmp). Across all 4 buffer sizes
the BUGGY transcription faults and the FIXED transcription does not.
Leak-zone variant (INFO LEAK)
The buffer is followed by a writable "leak zone" pre-filled with a fake
dirent whose d_ino=0xDEADBEEFCAFEBABE, d_type=0xEE, d_namlen=7,
d_name="HEAP-LE...". With d_namlen=7, DIRENT_DIRSIZ(fake)=24 < 32
(valid entry record size), so bytes stays positive after the OOB
increment and the loop body runs one more iteration, copying the OOB bytes
into the user sink. The harness checks the sink for the recognizable
"HEAP-LE" prefix β INFO LEAK CONFIRMED. Across all 4 buffer sizes the
BUGGY transcription leaks and the FIXED transcription does not.
Decisive output (from run.log, bufsiz=4096 case)
[guard-page variant] bufsiz=4096 name_len=8
filled bytes=4096 (== bufsiz ? YES β dp advances PAST allocation)
BUGGY loop: FAULT -> OOB READ CONFIRMED in for-increment `bytes -= DIRSIZ(dp)` (entries=128 bytes_after=32)
FIXED loop: no fault (loop terminated cleanly) (entries=128 bytes_after=0)
[leak-zone variant] bufsiz=4096 name_len=8
filled bytes=4096 (== bufsiz ? YES), leak zone @ 0x8004bb000, fake d_namlen=7 (DIRSIZ=24 < rec=32)
BUGGY loop: entries=139 bytes_after=-43944 ; sink has OOB marker ? YES -> INFO LEAK CONFIRMED
leaked marker at sink offset 4112: 'HEAP-LE'
FIXED loop: entries=128 bytes_after=0 ; sink has OOB marker ? no (clean termination)
The bytes_after=0 for the FIXED loop vs bytes_after=32 (guard) /
-43944 (leak) for the BUGGY loop proves the bytes accounting is repaired:
the fix makes the increment compute DIRSIZ of the just-processed entry and
correctly drives bytes to 0 after the last valid entry, so the loop
terminates without ever dereferencing an OOB dp.
Impact ceiling
Read-only primitive. The bug gives an attacker (who can mount a dirfs
filesystem on a vkernel and trigger readdir on a directory with enough
entries to fill the 4096-byte buffer) a kernel-heap OOB read and a
bounded info leak into the readdir result. There is no write, UAF, or
type-confusion component β no escalation chain exists. The realistic ceiling
is vkernel heap info leak (the leak could expose adjacent slab-chunk
contents / pointers, defeating KASLR-equivalent obscurity for a subsequent
attack on the vkernel, but the vkernel itself is the boundary, not the host
kernel). Runtime reachability is vkernel-only.
Fix
fix.diff removes line 1284 (dp = dpn) from the loop body. The
for-increment's own dp = dpn then handles the advancement, and
bytes -= _DIRENT_DIRSIZ(dp) correctly computes the size of the entry that
was just written out. This matches the finding markdown's ## Recommended
fix proposal exactly ("remove dp=dpn from the loop body; let the increment
handle advancement").
--- a/sys/vfs/dirfs/dirfs_vnops.c
+++ b/sys/vfs/dirfs/dirfs_vnops.c
@@ -1281,7 +1281,6 @@
if (error || r)
break;
dpn = _DIRENT_NEXT(dp);
- dp = dpn;
cnt++;
}
Phase 8 β fix validation
Live boot test is not possible (dirfs is vkernel-only; no vkernel runs on this guest). Validated to the maximum extent:
git apply --checkon a cleansys/tree β RC=0 (host-side).- Compile-neutrality:
dirfs_vnops.ccompiled PATCHED vs UNPATCHED with kernel build flags (-D_KERNEL,-Ipaths mapping<machine/*>to the vkernel64 platform headers). Both produce an IDENTICAL 6-error set:/usr/include/cpu/cpufunc.h:269:1: error: static declaration of 'ffs' follows non-static declaration /usr/include/cpu/cpufunc.h:277:1: error: static declaration of 'ffsl' follows non-static declaration /usr/include/cpu/cpufunc.h:285:1: error: static declaration of 'fls' follows non-static declaration /usr/include/cpu/cpufunc.h:293:1: error: static declaration of 'flsl' follows non-static declaration /usr/include/cpu/cpufunc.h:301:1: error: static declaration of 'flsll' follows non-static declaration /usr/src/sys/sys/ktr.h:47:10: fatal error: opt_ktr.h: No such file or directoryThese are pre-existing kernel/userland header-include artifacts (the kernel build proper generatesopt_ktr.hviaconfigand uses-nostdinc; my manual flags reproduce neither). The crucial fact is the diff between PATCHED and UNPATCHED is empty β the 1-line deletion introduces zero new compile errors. (Pre-existing dirfs_vnops.c-specifickmalloc/kfree/M_WAITOK/M_ZEROerrors seen by DF-0806 only surface afteropt_ktr.his provided; the comparison is valid at any depth because the fix is a deletion of a self-contained statement.) - Harness FIXED transcription: across all 4 buffer sizes, the FIXED loop
terminates cleanly (
bytes_after=0, no fault, no marker in sink). Seerun.logfor the full output.
fix_status: not_testable for live boot (dirfs not in host kernel, no
vkernel on guest) β the fix is validated to the maximum extent possible via
git apply --check + compile-neutrality + harness.
Reachability & preconditions (realism)
To trigger on a real vkernel deployment, an attacker must:
1. Run a vkernel (a DragonFly virtual kernel process β dirfs is vkernel-only).
2. Mount a dirfs filesystem inside the vkernel.
3. readdir a directory whose entry count fills the 4096-byte buffer
completely (β128+ entries with short names, or fewer with longer names
that pack tightly).
These are normal operations on a vkernel that uses dirfs. No special privilege inside the vkernel is required beyond filesystem access. The impact boundary is the vkernel process, not the host kernel.
Files in this evidence pack
| File | Purpose |
|---|---|
harness.c |
Deterministic loop transcription (guard-page + leak-zone variants) |
build.sh |
cc -O2 -Wall -o harness harness.c |
run.sh |
./harness |
build.log |
Full compiler output (clean build) |
run.log |
Full decisive harness run (all 4 buffer sizes) |
fix.diff |
git apply-able 1-line fix |
phase8_validate.sh |
Phase 8 compile-neutrality validation script |
fix_run.log |
Full Phase 8 validation output |
env.txt |
Guest environment (uname, cc version, dirfs absence) |
manifest.json |
Machine-readable artifact catalog |
Fix verification
not_testableVALIDATED to the maximum extent possible (live boot NOT TESTABLE: dirfs is vkernel64-only, absent from the host X86_64_GENERIC kernel and from /boot/kernel, and no vkernel runs on this guest). (1) git apply --check on a clean sys/ tree => RC=0. (2) Compile-neutrality: dirfs_vnops.c compiled PATCHED vs UNPATCHED with kernel build flags mapping
BASELINE (BUGGY transcription, bufsiz=4096): guard: FAULT -> OOB READ CONFIRMED (entries=128 bytes_after=32); leak: entries=139 bytes_after=-43944 ; sink has OOB marker ? YES -> INFO LEAK CONFIRMED. PATCHED (FIXED transcription, bufsiz=4096): guard: no fault (loop terminated cleanly) (entries=128 bytes_after=0); leak: entries=128 bytes_after=0 ; sink has OOB marker ? no (clean termination). Compile-neutrality: PATCHED error set == UNPATCHED error set (6 errors each, identical) -> zero new errors. git apply --check: RC=0 on clean sys/ tree.
Confirmed kernel references
Detail
Exploit chain
none -- this is a read-only primitive (heap OOB read + bounded info leak via bcopy/uiomove). There is no write, UAF, double-free, or type-confusion component, so no escalation chain exists. The realistic ceiling is vkernel heap info leak: an attacker who can trigger readdir on a dirfs mount inside a vkernel, with a directory holding enough entries to completely fill the 4096-byte getdirentries buffer, leaks attacker-recognizable bytes from the slab chunk adjacent to the dirfs readdir buffer into the readdir result. Impact boundary is the vkernel process, not the host kernel. Runtime reachability is vkernel-only (dirfs not in any host kernel config).
Evidence (decisive lines)
Scenario: bufsiz=4096 name_len=8 (128 entries, dirfs clamp): [guard-page variant] filled bytes=4096 (== bufsiz ? YES -- dp advances PAST allocation) BUGGY loop: FAULT -> OOB READ CONFIRMED in for-increment `bytes -= DIRSIZ(dp)` (entries=128 bytes_after=32); FIXED loop: no fault (loop terminated cleanly) (entries=128 bytes_after=0). [leak-zone variant] fake d_namlen=7 (DIRSIZ=24 < rec=32): BUGGY loop: entries=139 bytes_after=-43944 ; sink has OOB marker ? YES -> INFO LEAK CONFIRMED; leaked marker at sink offset 4112: 'HEAP-LE'; FIXED loop: entries=128 bytes_after=0 ; sink has OOB marker ? no (clean termination). Deterministic across 3 consecutive runs (run.log, run.2.log, run.3.log).
PoC changes
Created findings/poc/DF-0807/ from scratch. Wrote harness.c: a faithful transcription of the dirfs_readdir for-loop with two allocator styles -- (a) guard-page allocator (buf flush against PROT_NONE page) to definitively catch the OOB read in the for-increment as a SIGSEGV, and (b) leak-zone allocator (buf followed by a writable region pre-filled with a fake dirent d_ino=0xDEADBEEFCAFEBABE/d_type=0xEE/d_namlen=7/d_name='HEAP-LE...') to definitively show the info leak (recognizable marker surfaces in the user sink). Both BUGGY and FIXED transcriptions are run for 4 buffer sizes. Authored fix.diff (1-line deletion matching the finding proposal), build.sh/run.sh, VERDICT.md, phase8_validate.sh, manifest.json. Two iterations were needed: first version had mmap underflow for bufsiz>=page-size and a strstr-based leak check that didn't match the 7-byte d_namlen; fixed both (proper round_up_to_page allocator sizing + find_bytes helper + LEAK_PATTERN='HEAP-LE' matching the 7-byte copy).
Verified recommended fix
Remove the dp = dpn; line (currently line 1284) from the loop body in sys/vfs/dirfs/dirfs_vnops.c. The for-increment expression bytes -= _DIRENT_DIRSIZ(dp), dp = dpn already handles advancement; the body's premature dp = dpn makes the increment compute DIRSIZ of the wrong (next, eventually OOB) entry. With the line removed, bytes correctly decrements by the size of the just-processed entry and the loop terminates cleanly at bytes==0 after the last valid entry. This matches the finding markdown's ## Recommended fix proposal exactly ('remove dp=dpn from the loop body; let the increment handle advancement'). The full git-apply-able diff lives in findings/poc/DF-0807/fix.diff.
Verdict
REPRODUCED via deterministic harness (dirfs is vkernel64-only -- sys/platform/vkernel64/conf/files:45, NOT in sys/conf/files, absent from the running X86_64_GENERIC host kernel and from /boot/kernel; same harness-fallback precedent as DF-0806). The bug at sys/vfs/dirfs/dirfs_vnops.c:1277-1286 is confirmed: the loop body executes dp = dpn (line 1284) BEFORE the for-increment bytes -= _DIRENT_DIRSIZ(dp), dp = dpn (line 1278), so after the last valid entry the increment derefs dp->d_namlen past the buffer (heap OOB read), and if that OOB d_namlen is small enough the next iteration's vop_write_dirent (sys/kern/vfs_subr.c:2559-2582, bcopy at line 2575) copies OOB d_ino/d_type/d_namlen/d_name into the user readdir result (info leak). The harness proves both primitives across 4 buffer sizes (512/1024/2048/4096, the last matching the dirfs_readdir clamp at lines 1248-1249): guard-page allocator -> SIGSEGV on the OOB read; leak-zone allocator -> recognizable marker 'HEAP-LE' surfaces in the user sink. The FIXED transcription (dp=dpn removed from body) terminates cleanly with bytes==0, no fault, no leak in every case.
No comments yet.