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

Missing return after EINVAL in sys_read/sys_write/sys_extpwrite bypasses nbyte>SSIZE_MAX guard

Field Value
ID DF-0023
Status new
Severity Info
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:N
CWE CWE-754 Improper Check for Exceptional Conditions; CWE-696 Incorrect Behavior Order
File sys/kern/sys_generic.c
Lines 130-131, 161-162, 336-337, 368-369
Area kern
Confidence certain
Discovered 2026-06-29
Reported pending

Summary

In sys_read, sys_write, and sys_extpwrite, the guard if ((ssize_t)uap->nbyte < 0) error = EINVAL; assigns to a local error but does not return, so the dead-stored EINVAL is overwritten by the subsequent kern_preadv/kern_pwritev call and the ssize_t-range validation of the user-supplied size_t nbyte becomes a no-op. The sibling sys_extpread (:161-162) implements the same check correctly as return(EINVAL);, proving intent. A caller passing nbyte > SSIZE_MAX (bit 63 set) now proceeds into kern_preadv/kern_pwritev with auio.uio_resid set to that huge value. No kernel memory corruption or info leak is produced (downstream uiomove caps per-call counts and copyout/ copyin enforce the user address range), so this is a validation-correctness / defense-in-depth defect: the documented SSIZE_MAX contract is silently broken for every downstream file-ops implementation.

Root cause

sys/kern/sys_generic.c:

/* sys_read :130-131, sys_write :336-337, sys_extpwrite :368-369 */
if ((ssize_t)uap->nbyte < 0)
    error = EINVAL;          /* missing return */

aiov.iov_base = uap->buf;
aiov.iov_len  = uap->nbyte;
...
auio.uio_resid = uap->nbyte;     /* huge value reaches kern_preadv/pwritev */

Contrast the correct sibling sys_extpread :161-162:

if ((ssize_t)uap->nbyte < 0)
    return(EINVAL);

nbyte is size_t (sys/sys/sysproto.h); auio.uio_resid is size_t (sys/sys/_uio.h:69), so the huge value propagates verbatim.

Threat model & preconditions

  • Attacker position: any local unprivileged user.
  • Privileges gained or impact: none demonstrated. read/write/ extpwrite with nbyte > SSIZE_MAX proceed (loop until a natural boundary β€” EOF / empty socket buffer / EFAULT from the exhausted user buffer) instead of returning EINVAL. The ssize_t return value's range contract is broken, which could confuse a caller; downstream uiomove/copyout/copyin bound the actual transfer, so no kernel memory-safety impact was reproduced. Recorded as a correctness/defense-in-depth fix.
  • Required config or capabilities: none; default kernel.
  • Reachability: read(2)/write(2)/extpwrite(2) with nbyte > SSIZE_MAX.

Proof of concept

PoC source: findings/poc/DF-0023/einval_noop.c

Build & run (unprivileged)

cc -o einval_noop findings/poc/DF-0023/einval_noop.c
./einval_noop

Expected output (bug present)

read(fd,buf,SIZE_MAX) = 0, errno=0 (NOT EINVAL)
write(fd,buf,SSIZE_MAX+1) = 0, errno=0 (NOT EINVAL)

(Fixed: both -1, errno=EINVAL.)

Impact

Correctness / defense-in-depth. The SSIZE_MAX invariant relied on by the ssize_t return value and by any driver doing signed resid math is broken; no memory-safety impact was demonstrated because uiomove/copyout bound the transfer. Rated Info.

Add the missing return (mirroring sys_extpread):

--- a/sys/kern/sys_generic.c
+++ b/sys/kern/sys_generic.c
@@ -130,7 +130,7 @@
    if ((ssize_t)uap->nbyte < 0)
-       error = EINVAL;
+       return (EINVAL);
@@ -336,7 +336,7 @@
    if ((ssize_t)uap->nbyte < 0)
-       error = EINVAL;
+       return (EINVAL);
@@ -368,7 +368,7 @@
    if ((ssize_t)uap->nbyte < 0)
-       error = EINVAL;
+       return (EINVAL);

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/sys_generic.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0023 Β· 14 files
FileTypeDescriptionSize
einval_noop.c trigger-source minimal reproduction: read shows EINVAL-bypass, write forks a child to demonstrate unkillable kernel-loop DoS 4.1 KB view raw
write_only.c trigger-source separate probe for the sys_write path 1022 B view raw
build.sh build-script cc -o einval_noop / cc -o write_only 209 B view raw
run.sh run-script run einval_noop then write_only under timeout 415 B view raw
run.log run-log decisive UNPATCHED #0 run: read=0/errno0 NOT EINVAL; write HUNG unkillable 1.9 KB view raw
fix_build.log build-log make -j6 nativekernel single-fix kernel build, full output (rc=0) 5.6 MB ↓ download
fix_run.log run-log PATCHED #1 run: read=-1/EINVAL, write=-1/EINVAL (no hang), deterministic x3 1.1 KB view raw
fix_env.txt environment patched kernel kern.version #1 + sha256 213 B view raw
fix.diff suggested-fix git-apply-able: add `return (EINVAL)` at sys_generic.c:131,337,369 (mirrors sys_extpread:162) 649 B view raw
env.txt environment uname + cc version on unpatched #0 baseline 301 B view raw
README.md readme build/run/expected + mechanism 2.2 KB ↓ raw
VERDICT.md verdict full narrative: bug, DoS mechanism, fix, validation 6.3 KB ↓ 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
README.md readme build/run/expected + mechanism
↓ download raw

DF-0023 β€” PoC

einval_noop.c β€” read/write with nbyte > SSIZE_MAX do not return EINVAL because the guard in sys_read/sys_write/sys_extpwrite assigns error = EINVAL but never returns. The sibling sys_extpread does the same check correctly (return(EINVAL);), so the guard is provably a no-op.

write_only.c β€” separate probe for the sys_write path (sys/kern/sys_generic.c:336-337).

The bug

sys_read (sys/kern/sys_generic.c:130-131), sys_write (:336-337), sys_extpwrite (:368-369):

if ((ssize_t)uap->nbyte < 0)
    error = EINVAL;          /* NO return -> overwritten by kern_preadv/pwritev */

The sibling sys_extpread (:161-162) does the same check correctly: return(EINVAL);. So the nbyte > SSIZE_MAX guard is a no-op.

Observed impact

  1. Correctness (Info-rated): read(/dev/null, buf, SIZE_MAX) returns 0 errno=0 instead of -1/EINVAL. The POSIX ssize_t return contract is silently broken.
  2. Local DoS (stronger than rated): write(/dev/null, buf, SSIZE_MAX+1) hangs in an infinite, uninterruptible kernel loop in kern_memio.c:mmrw (u_int c truncates the 64-bit iov_len to 0, so uio_resid never decreases). SIGKILL cannot reap the process β€” it never leaves the kernel. An unprivileged user can pin every CPU core with unkillable processes.

Build & run (unprivileged)

./build.sh          # cc -o einval_noop einval_noop.c; cc -o write_only write_only.c
./run.sh            # runs einval_noop, then write_only under `timeout 12`

Expected output

Bug present (unpatched #0):

read(fd,buf,SIZE_MAX) = 0, errno=0 (NOT EINVAL)
write child PID <n>: HUNG in kernel after 4s (DoS - uninterruptible infinite loop in mmrw)
after SIGKILL: child STILL ALIVE (unkillable in kernel loop)
PATCHED write(fd,buf,SSIZE_MAX+1) = <never returns; timed out>

Fixed (patched #1):

read(fd,buf,SIZE_MAX) = -1, errno=22 (EINVAL)
PATCHED write(fd,buf,SSIZE_MAX+1) = -1, errno=22 (EINVAL)

No memory corruption is produced (downstream uiomove/copyout bound any data transfer to the user address range), so this is a correctness + local-DoS finding, not a privilege-escalation primitive.

VERDICT.md verdict full narrative: bug, DoS mechanism, fix, validation
↓ download raw

DF-0023 β€” Verdict

Verdict: REPRODUCED (and the impact is stronger than the Info rating β€” it is also a local DoS, not merely a correctness/defense-in-depth nit). Fix: VALIDATED on a built-and-booted single-fix kernel.


The bug (confirmed line-by-line)

sys/kern/sys_generic.c has three syscall entry points that validate the user-supplied nbyte (a size_t) against SSIZE_MAX by assigning EINVAL to a local error β€” but never returning:

Function Lines Code (buggy)
sys_read 130–131 if ((ssize_t)uap->nbyte < 0) error = EINVAL;
sys_write 336–337 if ((ssize_t)uap->nbyte < 0) error = EINVAL;
sys_extpwrite 368–369 if ((ssize_t)uap->nbyte < 0) error = EINVAL;

The dead-stored EINVAL is unconditionally overwritten a few lines later:

error = kern_preadv(uap->fd, &auio, 0, &sysmsg->sysmsg_szresult);   /* sys_read  :143 */
error = kern_pwritev(uap->fd, &auio, 0, &sysmsg->sysmsg_szresult);  /* sys_write :349 */
error = kern_pwritev(uap->fd, &auio, flags, &sysmsg->sysmsg_szresult); /* sys_extpwrite :384 */
return(error);

so the SSIZE_MAX guard is a complete no-op. The sibling sys_extpread (:161-162) implements the identical check correctly as return(EINVAL);, proving the intent and giving the fix its template.

Reproduction (unpatched #0 kernel)

Two observable effects, both reachable by any unprivileged user via the default read(2)/write(2) surface:

1. Correctness bypass β€” read

$ ./einval_noop
read(fd,buf,SIZE_MAX) = 0, errno=0 (NOT EINVAL)

read(/dev/null, buf, SIZE_MAX) returns 0 with errno=0 instead of the POSIX-mandated -1/EINVAL. The ssize_t return-range contract is silently broken for every file-ops implementation downstream. (This is the finding's documented Info-level effect.)

2. Local DoS β€” write (stronger than rated)

write child PID 869: probing write(fd,buf,SSIZE_MAX+1)...
write child PID 869: HUNG in kernel after 4s (DoS - uninterruptible infinite loop in mmrw)
after SIGKILL: child STILL ALIVE (unkillable in kernel loop)

write(/dev/null, buf, 0x8000000000000000) enters an infinite, uninterruptible kernel loop and the calling process becomes unkillable (SIGKILL cannot be delivered β€” it never leaves the kernel to take the signal). Mechanism, traced end-to-end:

sys_write        sys_generic.c:336-337   dead-store EINVAL -> falls through
  kern_pwritev   sys_generic.c:456       holdfp(FWRITE), no nbyte re-check
    dofilewrite  sys_generic.c:506       fo_write(fp, auio, ...)
      mmwrite    kern_memio.c:396        -> mmrw(dev, uio, flags)
        mmrw     kern_memio.c:222

Inside mmrw (kern_memio.c:225-383): - line 225 declares u_int c; (32-bit) - the /dev/null write arm (minor 2, line 292-299) does c = iov->iov_len; where iov->iov_len is size_t (64-bit) = 0x8000000000000000, so c truncates to 0; - the loop tail (line 379-382) does uio->uio_resid -= c; β‡’ subtracts 0, so while (uio->uio_resid > 0) (line 232) never terminates; - the loop body performs no signal/CURSIG check, so the LWP is never interrupted β€” SIGKILL is queued but never delivered.

An unprivileged user can therefore permanently pin every CPU core with unkillable processes via one write() syscall each. This is a genuine local DoS β€” higher than the finding's Info rating, which anticipated the loop would exit "at a natural boundary (EOF / empty socket buffer / EFAULT)". For /dev/null there is no such boundary because c truncates to 0.

Note: this DoS is caused by DF-0023's missing return β€” without the missing return, nbyte > SSIZE_MAX is rejected at the syscall layer and mmrw is never reached with a pathological uio_resid. The u_int c truncation in mmrw is a contributing latent defect, but DF-0023's missing return is the unprivileged trigger and the minimal fix.

Non-corruption class β†’ no escalation chain

This is a logic/DoS bug, not memory corruption. No slab grooming, UAF, type-confusion, or arbitrary write is produced (uiomove/copyout/copyin bound any actual data transfer to the user address range). Phase 6 (escalation to uid=0) is therefore not applicable.

PoC changes

  • einval_noop.c β€” reworked to (a) flush stderr before each syscall so output survives a panic/wedge, (b) probe read first (clean EINVAL-bypass demonstration), then (c) fork a child for the write probe so the parent survives to report the hang and prove the child is unkillable. Added #include <signal.h> (the original used kill/SIGKILL without it and failed to compile on DragonFlyBSD gcc 8.3).
  • write_only.c β€” new, separate probe for the sys_write path so the write location can be exercised without forking (used for the patched- kernel confirmation that write now returns EINVAL).

The fix (fix.diff)

Add the missing return in all three sites, mirroring the correct sys_extpread:

-   if ((ssize_t)uap->nbyte < 0)
-       error = EINVAL;
+   if ((ssize_t)uap->nbyte < 0)
+       return (EINVAL);

at sys/kern/sys_generic.c:130-131 (sys_read), :336-337 (sys_write), :368-369 (sys_extpwrite). This matches the finding markdown's ## Recommended fix proposal (the markdown also noted the correct sys_extpread template). The diff is git apply-clean (git apply --check passes) with correct hunk line numbers.

Fix validation (Phase 8)

  • Baseline (unpatched #0, 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026): read returns 0 errno=0 (NOT EINVAL); write hangs in an unkillable kernel loop. (run.log)
  • Applied ONLY fix.diff to /usr/src, built make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, ~7 min, warm obj), swapped /boot/kernel/kernel ← kernel.stripped, rebooted cleanly.
  • Patched (#1, 6.5-DEVELOPMENT #1 Tue Jul 14 19:31:57 UTC 2026, sha256 c9a066…d7b8d43):
  • read(fd,buf,SIZE_MAX) = -1, errno=22 (EINVAL) βœ…
  • write(fd,buf,SSIZE_MAX+1) = -1, errno=22 (EINVAL) βœ… (no hang)
  • guest stays up; deterministic across 3 runs. (fix_run.log)

fix_status = fixed β€” clean before/after on a built-and-booted single-fix kernel. The EINVAL guard now behaves identically to sys_extpread, and the local-DoS hang via /dev/null write is eliminated.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline read=0 hang; patched read=-1 EINVAL, write=-1 EINVAL no hang x3.

BEFORE: read=0 errno=0, write HUNG. AFTER: read=-1 EINVAL, write=-1 EINVAL.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 19:31:57 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- non-corruption (correctness bypass + local DoS). Unkillable CPU-pin via write(/dev/null, SSIZE_MAX+1). No memory corruption.

Evidence (decisive lines)

BEFORE: read(SIZE_MAX)=0 errno=0; write child HUNG unkillable. AFTER: read=-1 EINVAL; write=-1 EINVAL no hang.

PoC changes

Rewrote einval_noop.c + write_only.c, fix.diff (error=EINVAL -> return(EINVAL) at 3 sites), VERDICT.md, manifest.json.

Verified recommended fix

Change error=EINVAL to return(EINVAL) at sys_generic.c:131(sys_read)/337(sys_write)/369(sys_extpwrite), mirroring sys_extpread:162. Matches finding proposal. Full diff in findings/poc/DF-0023/fix.diff.

Verdict

REPRODUCED. Missing return after error=EINVAL at sys_generic.c:131(sys_read)/337(sys_write)/369(sys_extpwrite). EINVAL dead-stored, overwritten by kern_preadv/pwritev. read(SIZE_MAX) returns 0 not EINVAL. write(SSIZE_MAX+1) -> kern_memio u_int truncation -> unkillable infinite loop. Stronger than Info: local DoS.