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_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/extpwritewithnbyte > SSIZE_MAXproceed (loop until a natural boundary β EOF / empty socket buffer / EFAULT from the exhausted user buffer) instead of returningEINVAL. Thessize_treturn value's range contract is broken, which could confuse a caller; downstreamuiomove/copyout/copyinbound 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)withnbyte > 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.
Recommended fix
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
sys/kern/sys_generic.c:130-131βsys_readmissing return.sys/kern/sys_generic.c:336-337βsys_writemissing return.sys/kern/sys_generic.c:368-369βsys_extpwritemissing return.sys/kern/sys_generic.c:161-162βsys_extpread(correct pattern).- CWE-754, CWE-696.
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)
PoC verification
Evidence pack
findings/poc/DF-0023 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
- Correctness (Info-rated):
read(/dev/null, buf, SIZE_MAX)returns0 errno=0instead of-1/EINVAL. The POSIXssize_treturn contract is silently broken. - Local DoS (stronger than rated):
write(/dev/null, buf, SSIZE_MAX+1)hangs in an infinite, uninterruptible kernel loop inkern_memio.c:mmrw(u_int ctruncates the 64-bitiov_lento 0, souio_residnever decreases).SIGKILLcannot 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.
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_MAXis rejected at the syscall layer andmmrwis never reached with a pathologicaluio_resid. Theu_int ctruncation inmmrwis 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) probereadfirst (clean EINVAL-bypass demonstration), then (c) fork a child for thewriteprobe so the parent survives to report the hang and prove the child is unkillable. Added#include <signal.h>(the original usedkill/SIGKILLwithout it and failed to compile on DragonFlyBSD gcc 8.3).write_only.cβ new, separate probe for thesys_writepath so thewritelocation can be exercised without forking (used for the patched- kernel confirmation thatwritenow 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 #0Thu Jul 2 06:02:54 UTC 2026):readreturns0 errno=0(NOT EINVAL);writehangs in an unkillable kernel loop. (run.log) - Applied ONLY
fix.diffto/usr/src, builtmake -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 #1Tue Jul 14 19:31:57 UTC 2026, sha256c9a066β¦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
fixedVALIDATED: 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.
Confirmed kernel references
- sys/kern/sys_generic.c:130
- sys/kern/sys_generic.c:131
- sys/kern/sys_generic.c:143
- sys/kern/sys_generic.c:336
- sys/kern/sys_generic.c:337
- sys/kern/sys_generic.c:368
- sys/kern/sys_generic.c:369
- sys/kern/sys_generic.c:161
- sys/kern/sys_generic.c:162
- sys/kern/kern_memio.c:225
- sys/kern/kern_memio.c:232
- sys/kern/kern_memio.c:298
- sys/kern/kern_memio.c:382
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.
No comments yet.