dirfs_readlink off-by-one heap overflow and OOB read via truncated symlink target
Summary
dirfs_vnops.c:1328 buf=kmalloc(uio->uio_resid) allocates exactly N bytes. :1329 nlen=readlinkat(...buf...uio_resid) POSIX returns up to bufsiz nlen==N when target>=N. :1334 buf[nlen]=\0 writes NUL at index N one past allocation = 1-byte heap overflow. :1333 uiomove(buf,nlen+1,uio) copies N+1 bytes from N-byte buffer = 1-byte OOB read. uio_resid from kern_readlink raw user count no clamp no validation. Trigger: symlink target>=uio_resid then readlink(path,buf,N). Guest user on dirfs mount (vkernel default FS). Fix: kmalloc(uio_resid+1) + uiomove(buf,nlen) not nlen+1.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0806 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic guard-page transcription of dirfs_readlink :1328-1334 (OOB write + OOB read detection) | 8.3 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o harness harness.c | 131 B | view raw |
| run.sh | run-script | ./harness (several kmalloc bucket sizes) | 126 B | view raw |
| build.log | build-log | final successful harness build | 66 B | view raw |
| run.log | run-log | decisive harness run: BUG CONFIRMED + FIX VALIDATED | 2.6 KB | view raw |
| run.stress.log | run-log | two additional runs (deterministic, byte-identical) | 489 B | view raw |
| fix.diff | suggested-fix | kmalloc(uio_resid+1) + uiomove(buf,nlen); git-apply-able, applies cleanly (RC=0) | 606 B | view raw |
| fix_run.log | fix-log | Phase 8: compile-neutrality proof (patched==unpatched errors; dirfs_vfsops.o built clean) + patched source lines | 1.5 KB | view raw |
| env.txt | environment | uname, cc, dirfs absence in kernel/modules, config presence (vkernel-only), fix applied | 981 B | view raw |
| VERDICT.md | verdict | full narrative: line-by-line mechanism, reachability, harness proof, impact ceiling, fix validation | 8.2 KB | β raw |
| manifest.json | manifest | this catalog | 2.9 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-0806 β dirfs_readlink off-by-one heap overflow + OOB read
Verdict
REPRODUCED (deterministic harness) β 1-byte heap overflow (CWE-787) AND
1-byte OOB read (CWE-125) confirmed in dirfs_readlink. The fix.diff
(kmalloc(uio_resid+1) + uiomove(buf, nlen)) is compile-validated and
harness-validated; a live-kernel boot test is not_testable because dirfs
is vkernel-only (not compiled into the running X86_64_GENERIC host kernel
and no vkernel runs on this guest).
The bug β line-by-line (sys/vfs/dirfs/dirfs_vnops.c)
1328: buf = kmalloc(uio->uio_resid, M_DIRFS_MISC, M_WAITOK | M_ZERO);
1329: nlen = readlinkat(pathnp->dn_fd, dnp->dn_name, buf, uio->uio_resid);
1330: if (nlen == -1 ) {
1331: error = errno;
1332: } else {
1333: error = uiomove(buf, nlen + 1, uio); // reads nlen+1 bytes
1334: buf[nlen] = '\0'; // writes at index nlen
- :1328 allocates exactly N =
uio->uio_residbytes (M_ZERO is cosmetic for the overflow; it only zeroes the N allocated bytes, not byte N). - :1329
readlinkat(..., buf, uio->uio_resid)β POSIX returns up tobufsiz; when the symlink target length β₯ N it returnsnlen == N(the buffer is exactly full, no room for a terminator). - :1334
buf[nlen] = '\0'βbuf[N] = '\0'β writes 1 byte past the N-byte allocation β CWE-787 off-by-one heap overflow (OOB write). - :1333
uiomove(buf, nlen + 1, uio)β copiesN+1bytes from anN-byte buffer β CWE-125 1-byte OOB read (the extra byte is also leaked to the user via the UIO_READuio).
uio->uio_resid is the raw user-supplied count, unclamped:
sys/kern/vfs_syscalls.c:3211 auio.uio_resid = count; (kern_readlink), where
count is the user's readlink(path, buf, count) argument. There is no upper
bound or validation, so any N β₯ 1 is reachable.
Reachability on this guest (why a harness)
dirfs is vkernel-only. Confirmed:
- grep -c dirfs /usr/src/sys/conf/files β 0 (not in the host-kernel file
list).
- grep -c dirfs /usr/src/sys/platform/vkernel64/conf/files β 3
(dirfs_vnops.c, dirfs_vfsops.c, dirfs_subr.c as optional dirfs).
- /boot/kernel/dirfs* β does not exist; kldstat | grep dirfs β none.
- The running kernel is 6.5-DEVELOPMENT #0 (X86_64_GENERIC) which does not
include options DIRFS.
dirfs is a pass-through filesystem that runs inside a vkernel (a userspace
process that simulates a kernel). There is no vkernel running on this guest,
and even building+booting one and mounting a dirfs inside it to reach
dirfs_readlink is not feasible in this single-tenant host-kernel guest.
Per the finding's authorization and the spec's dead-code/latent-bug clause
(DF-0594/0616/0281 precedent), the deterministic harness is the accepted
proof: it transcribes the exact buggy operations (:1328β:1334) with a
guard-page allocator so any access to buf[N] faults deterministically.
Reproduction β harness (harness.c)
./harness exercises N β {16, 32, 64, 128, 256} (kmalloc buckets). For every N:
VULNERABLE dirfs_readlink transcription (kmalloc(N), nlen=N): line 1334 buf[nlen]='\0' : FAULT (1-byte heap overflow / OOB WRITE confirmed) line 1333 uiomove(buf,N+1) : FAULT (1-byte OOB READ confirmed) FIXED transcription (kmalloc(N+1), uiomove(buf,nlen)): buf[nlen]='\0' : no fault (in-bounds) uiomove(buf,N) : no fault (in-bounds) => BUG PRESENT (OOB detected); FIX VALID (no OOB)
Result over 3 runs is byte-identical (guard-page detection is deterministic).
Full output: run.log; stress runs: run.stress.log.
Impact ceiling
- 1-byte heap overflow writing
'\0'one past akmalloc(N)buffer (M_DIRFS_MISCbucket), plus a 1-byte OOB read leaked to userspace via theuiomoveUIO_READ. - The write value is a fixed
0x00(NUL), not fully attacker-controlled. - On a vkernel deployment, this corrupts the vkernel process's heap (dirfs code runs in the vkernel userspace process). It is a vkernel-heap corruption primitive, not a host-kernel corruption.
- This guest runs the host kernel with no dirfs, so the primitive is not
live-reachable here; the harness is the proof at the object/transcription
level. The honest demonstrated impact is the confirmed memory-corruption
primitive (1-byte OOB write + 1-byte OOB read); escalation to
uid=0was neither attempted nor claimed because the vulnerable code path is not reachable on this guest's default kernel. - INVARIANTS (ON on GENERIC) would, in a vkernel build with INVARIANTS, catch
cross-slab grooming via the
kern_slaballoc.cmagic/poison checks; on this guest that is moot (dirfs absent).
Exploit chain
none (not applicable). This is a vkernel-only memory-corruption primitive
that is not reachable on this guest's default X86_64_GENERIC kernel, so there
is no userspace uid=0 chain to develop or demonstrate here. The realistic
ceiling is the confirmed 1-byte heap overflow + 1-byte OOB read, characterized
via the deterministic harness.
The fix β fix.diff
- buf = kmalloc(uio->uio_resid, M_DIRFS_MISC, M_WAITOK | M_ZERO);
+ buf = kmalloc(uio->uio_resid + 1, M_DIRFS_MISC, M_WAITOK | M_ZERO);
nlen = readlinkat(pathnp->dn_fd, dnp->dn_name, buf, uio->uio_resid);
if (nlen == -1 ) {
error = errno;
} else {
- error = uiomove(buf, nlen + 1, uio);
+ error = uiomove(buf, nlen, uio);
buf[nlen] = '\0';
- Allocate
N+1bytes sobuf[N](the NUL terminator at indexnlen==N) is in-bounds. uiomove(buf, nlen, uio)copies exactlynlenbytes β no OOB read. The NUL is not part of the data returned to userspace byreadlink()(POSIXreadlinkdoes not NUL-terminate); the originalnlen+1was the bug, copy the actual link length only.
The harness "FIXED transcription" path proves the logic closes the OOB
deterministically across all tested bucket sizes (no fault on buf[N] access
with the N+1 allocation).
Fix validation (Phase 8)
git apply --check/patch --dry-runon a clean/usr/srctree β RC=0 (applies cleanly).- Patched source reads correctly:
:1328βkmalloc(uio->uio_resid + 1, ...);:1333βuiomove(buf, nlen, uio). - Compile neutrality proven: dirfs is
optional dirfsonly insys/platform/vkernel64/conf/files, so aVKERNEL64_DIRFSconfig was generated (config -d β¦/compile/VKERNEL64_DIRFS VKERNEL64_DIRFS). Compiling the patcheddirfs_vnops.ostandalone fails onM_WAITOK/M_ZERO/uiomove/kmalloc/kfreeundeclared β but compiling the UNPATCHEDdirfs_vnops.ofails with the identical error set at the identical lines (653, 1057, 1078, 1186, 1250, 1328).dirfs_vnops.csimply omits the<sys/malloc.h>/<sys/uio.h>includes that its siblingdirfs_vfsops.chas, so it does not stand alone outside the full vkernel build. The siblingdirfs_vfsops.odid compile cleanly (190 KB) in the same vkernel build environment. Conclusion: the fix introduces zero new compile errors (patched β‘ unpatched); the residual standalone failure is a pre-existing missing-transitive-include issue indirfs_vnops.c, unrelated to this fix. - Live boot test:
not_testableβ dirfs is not in the host kernel and no vkernel runs on this guest, so thereadlinkβdirfs_readlinkpath cannot be exercised on a live kernel here. The harness transcription is the deterministic proof that the fix closes the OOB.
PoC changes
harness.c(pre-existing from the aborted run) β kept as-is; it is a correct deterministic transcription of:1328β:1334with a guard-page allocator. No source changes were needed; it builds and runs cleanly.build.sh/run.sh(pre-existing) β kept as-is.fix.diff(pre-existing) β confirmed correct (git apply --checkRC=0, compile-neutral, harness-validates).- Regenerated:
build.log,run.log,run.stress.log,env.txt,fix_run.log(full evidence of this run).
How to reproduce
ssh dfbsd-maxx # unprivileged (uid 1001) cd poc/DF-0806 ./build.sh && ./run.sh # expected: "BUG CONFIRMED + FIX VALIDATED" with FAULT lines for the # vulnerable transcription and "no fault (in-bounds)" for the fixed.
Fix verification
not_testablenot_testable for a live boot test: dirfs is 'optional dirfs' only in sys/platform/vkernel64/conf/files (0 occurrences in sys/conf/files), absent from /boot/kernel and kldstat on the running X86_64_GENERIC host kernel, and no vkernel runs on this guest -- so the readlink->dirfs_readlink code path cannot be exercised on a live kernel here, and no single-fix kernel can be booted that contains dirfs. Fix was validated to the maximum extent possible: (a) git apply --check on a clean /usr/src tree => RC=0; (b) patched source reads correctly (:1328 kmalloc(uio_resid+1), :1333 uiomove(buf,nlen)); (c) COMPILE-NEUTRALITY PROVEN -- built a VKERNEL64_DIRFS config and compiled dirfs_vnops.o both PATCHED and UNPATCHED; both fail with the identical error set (M_WAITOK/M_ZERO/uiomove/kmalloc/kfree undeclared at lines 653/1057/1078/1186/1250/1328) because dirfs_vnops.c omits
baseline (harness, vulnerable transcription, N=16): line 1334 buf[nlen]='\0' : FAULT (1-byte heap overflow / OOB WRITE confirmed); line 1333 uiomove(buf,N+1) : FAULT (1-byte OOB READ confirmed). patched (harness, fixed transcription kmalloc(N+1)+uiomove(buf,nlen), N=16): buf[nlen]='\0' : no fault (in-bounds); uiomove(buf,N) : no fault (in-bounds). compile-neutrality: PATCHED dirfs_vnops.o errors == UNPATCHED dirfs_vnops.o errors (identical M_WAITOK/M_ZERO/uiomove set at lines 653/1057/1078/1186/1250/1328); sibling dirfs_vfsops.o BUILT CLEAN (190704 bytes) in the same VKERNEL64_DIRFS build env => fix adds no compile errors. git apply --check on clean /usr/src => RC=0
Confirmed kernel references
- sys/vfs/dirfs/dirfs_vnops.c:1328
- sys/vfs/dirfs/dirfs_vnops.c:1329
- sys/vfs/dirfs/dirfs_vnops.c:1333
- sys/vfs/dirfs/dirfs_vnops.c:1334
- sys/kern/vfs_syscalls.c:3211
- sys/platform/vkernel64/conf/files:optional dirfs
Detail
Exploit chain
none (not applicable -- non-reachable primitive on this guest). dirfs is a vkernel-only filesystem (a userspace-process kernel); it is not compiled into the host X86_64_GENERIC kernel and no vkernel runs on this guest, so the readlink->dirfs_readlink path cannot be triggered from userspace here and there is no uid=0 chain to develop or demonstrate on the default kernel. The realistic impact ceiling is the confirmed memory-corruption primitive itself: a 1-byte heap overflow writing a fixed NUL (0x00) one past a kmalloc(N) buffer in the M_DIRFS_MISC bucket plus a 1-byte OOB read leaked to userspace via the UIO_READ uio. On a vkernel deployment this would be a vkernel-process heap corruption primitive (not a host-kernel compromise). This is a valid 'dead/unreachable at runtime on this guest' stop: the primitive is proven at the harness/object level and the live trigger conditions (a running vkernel with a mounted dirfs and a symlink whose target length >= the user's readlink buffer size) are documented. No file written for a chain because none is applicable.
Evidence (decisive lines)
[kmalloc-16 bucket] N=16: VULNERABLE dirfs_readlink transcription (kmalloc(N), nlen=N): line 1334 buf[nlen]='\0' : FAULT (1-byte heap overflow / OOB WRITE confirmed); line 1333 uiomove(buf,N+1) : FAULT (1-byte OOB READ confirmed). FIXED transcription (kmalloc(N+1), uiomove(buf,nlen)): buf[nlen]='\0' : no fault (in-bounds); uiomove(buf,N) : no fault (in-bounds) => BUG PRESENT (OOB detected); FIX VALID (no OOB). ...(identical for N=32,64,128,256)... === SUMMARY === Overall: BUG CONFIRMED + FIX VALIDATED
PoC changes
Reused the partial evidence pack from the aborted run (harness.c, build.sh, run.sh, fix.diff) without source changes -- the harness is a correct deterministic guard-page transcription of dirfs_readlink :1328-1334 and builds/runs cleanly. Regenerated fresh evidence: build.log, run.log (decisive), run.stress.log (2 extra deterministic runs), env.txt (uname, cc, dirfs absence, config presence, patched source lines), fix_run.log (Phase 8 compile-neutrality proof). Wrote VERDICT.md (full narrative) and manifest.json.
Verified recommended fix
In sys/vfs/dirfs/dirfs_vnops.c change :1328 from kmalloc(uio->uio_resid,...) to kmalloc(uio->uio_resid + 1,...) so the buf[nlen]='\0' terminator at index N is in-bounds, and change :1333 from uiomove(buf, nlen + 1, uio) to uiomove(buf, nlen, uio) so only the actual link-length bytes are copied (POSIX readlink does not NUL-terminate; the +1 was the OOB-read bug). matches finding proposal. The full git-apply-able diff lives in findings/poc/DF-0806/fix.diff (applies cleanly, RC=0).
Verdict
REPRODUCED. dirfs_readlink at sys/vfs/dirfs/dirfs_vnops.c:1328-1334 is a genuine off-by-one: :1328 kmalloc(uio->uio_resid) allocates exactly N bytes; :1329 readlinkat(...,buf,uio_resid) returns nlen==N when symlink target length>=N (POSIX); :1334 buf[nlen]='\0' writes NUL at index N (1-byte heap overflow / CWE-787); :1333 uiomove(buf,nlen+1,uio) copies N+1 bytes from an N-byte buffer (1-byte OOB read / CWE-125). uio_resid flows unclamped from the user's readlink() count via kern_readlink (sys/kern/vfs_syscalls.c:3211). Confirmed by a deterministic guard-page harness that transcribes :1328-1334 exactly: every kmalloc bucket size N in {16,32,64,128,256} faults on buf[N] for the vulnerable transcription and does NOT fault for the fixed transcription (kmalloc(N+1)+uiomove(buf,nlen)). The live readlink path is not reachable on this guest because dirfs is vkernel-only (grep -c dirfs sys/conf/files => 0; only in sys/platform/vkernel64/conf/files as 'optional dirfs'; no /boot/kernel/dirfs* and not in kldstat on the running X86_64_GENERIC #0 kernel), so the harness is the accepted proof per the latent/dead-path clause.
No comments yet.