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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0806 Β· 13 files
FileTypeDescriptionSize
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
VERDICT.md verdict full narrative: line-by-line mechanism, reachability, harness proof, impact ceiling, fix validation
↓ download 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_resid bytes (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 to bufsiz; when the symlink target length β‰₯ N it returns nlen == 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) β‡’ copies N+1 bytes from an N-byte buffer β‡’ CWE-125 1-byte OOB read (the extra byte is also leaked to the user via the UIO_READ uio).

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 a kmalloc(N) buffer (M_DIRFS_MISC bucket), plus a 1-byte OOB read leaked to userspace via the uiomove UIO_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=0 was 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.c magic/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+1 bytes so buf[N] (the NUL terminator at index nlen==N) is in-bounds.
  • uiomove(buf, nlen, uio) copies exactly nlen bytes β€” no OOB read. The NUL is not part of the data returned to userspace by readlink() (POSIX readlink does not NUL-terminate); the original nlen+1 was 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-run on a clean /usr/src tree β‡’ 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 dirfs only in sys/platform/vkernel64/conf/files, so a VKERNEL64_DIRFS config was generated (config -d …/compile/VKERNEL64_DIRFS VKERNEL64_DIRFS). Compiling the patched dirfs_vnops.o standalone fails on M_WAITOK/M_ZERO/ uiomove/kmalloc/kfree undeclared β€” but compiling the UNPATCHED dirfs_vnops.o fails with the identical error set at the identical lines (653, 1057, 1078, 1186, 1250, 1328). dirfs_vnops.c simply omits the <sys/malloc.h>/<sys/uio.h> includes that its sibling dirfs_vfsops.c has, so it does not stand alone outside the full vkernel build. The sibling dirfs_vfsops.o did 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 in dirfs_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 the readlinkβ†’dirfs_readlink path 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–:1334 with 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 --check RC=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_testable
baseline reproduced→ patch + rebuild →patched clean

not_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 / (unlike sibling dirfs_vfsops.c, whose dirfs_vfsops.o compiled cleanly at 190 KB); therefore the fix introduces zero new compile errors; (d) the harness FIXED transcription deterministically shows no OOB across all tested kmalloc buckets. The 2-line change is type-preserving and trivially correct.

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
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC, host kernel -- dirfs is vkernel-only and cannot be built into or booted on this guest)

Confirmed kernel references

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.