uiomovez() copyouts unbounded cnt from PAGE_SIZE-sized ZeroPage β OOB kernel heap read into userspace (latent: sole in-tree caller passes UIO_SYSSPACE)
| Field | Value |
|---|---|
| ID | DF-2886 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/kern/kern_subr.c |
| Lines | 241-246 (ZeroPage kern_slaballoc.c:316 = PAGE_SIZE) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | kernleak |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
uiomovez()'s UIO_USERSPACE branch copyouts cnt = min(iov_len, n) bytes from ZeroPage, but ZeroPage is allocated as exactly PAGE_SIZE bytes. cnt is never clamped to PAGE_SIZE, so any call with n > 4096 copies nβ4096 bytes of kernel heap adjacent to ZeroPage into the user's buffer while claiming to zero-fill. Verified on the guest: a KLD harness calling uiomovez(65536) through a plain read() delivered 64 stable non-zero kernel-heap bytes (slab allocator bookkeeping) into an unprivileged (nobody) buffer in 4/4 runs, identical across the first three. Today latent: the only in-tree caller (NFSv3 short-read zero-fill, nfs_vnops.c:1433, up to 32KB on a malicious server reply) reaches it only with UIO_SYSSPACE (safe bzero branch) β any current or future caller zero-filling >4096 bytes into a UIO_USERSPACE uio gets an immediate ~60KB-class kernel heap disclosure.
Recommended fix
Clamp cnt to PAGE_SIZE per loop iteration (validated fix.diff in findings/poc/DF-2886/; loop re-drives on remaining n/iov so semantics identical β patched kernel: 0 non-zero bytes, still 65536 zeros).
Timeline
- 2026-09-02 Discovered during pass-2 audit of kern_subr.c (GLM 5.3); KLD leak reproduced 4/4 + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2886 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| uiozleak.c | β | 1.8 KB | view raw | |
| uioz_trigger.c | β | 2.2 KB | view raw | |
| build.sh | β | 317 B | view raw | |
| run.sh | β | 422 B | view raw | |
| build.log | β | 5.4 KB | view raw | |
| run.log | β | 4.0 KB | view raw | |
| run.fixed.log | β | 605 B | view raw | |
| leak_sample_run1.txt | β | 2.3 KB | view raw | |
| leak_sample_run2.txt | β | 2.3 KB | view raw | |
| leak_sample_run3.txt | β | 2.3 KB | view raw | |
| env.txt | β | 390 B | view raw | |
| fix.diff | β | 440 B | view raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| verdict.json | β | 3.9 KB | view raw |
DF-2886 VERDICT β uiomovez() reads past ZeroPage into userspace
Bottom line
REPRODUCED (impact: leak). uiomovez()'s UIO_USERSPACE branch
performs copyout(ZeroPage, iov->iov_base, cnt) with cnt bounded only
by min(iov_len, n), while ZeroPage is a fixed PAGE_SIZE (4096)
allocation (sys/kern/kern_slaballoc.c:316). For any cnt > 4096 the
kernel copies cnt - 4096 bytes of adjacent kernel heap into the
calling process's buffer. A KLD harness driving the engine's public API
delivered 64 stable non-zero kernel bytes (slab-allocator bookkeeping)
into an unprivileged (nobody) process's read buffer across 3+1 runs,
identical byte-for-byte across the first three. The one-hunk fix
(clamp cnt to PAGE_SIZE) was built as kernel #1 and eliminates every
non-zero byte while preserving the zero-fill contract (read() still
returns 65536 zeros). No panic; system stable before/after.
Why it leaks (path:line)
- sys/kern/kern_subr.c:241-242 β
if (cnt > n) cnt = n;β no PAGE_SIZE clamp - sys/kern/kern_subr.c:246 β
error = copyout(ZeroPage, iov->iov_base, cnt); - sys/kern/kern_slaballoc.c:316 β
ZeroPage = kmem_slab_alloc(PAGE_SIZE, PAGE_SIZE, M_WAITOK|M_ZERO); - Leak content on this guest: sparse small integers in the 60KB window after ZeroPage (per-CPU slab-zone bookkeeping); identical across runs within a boot β see leak_sample_run{1,2,3}.txt. Content is live kernel heap (run 4 showed 63 vs 64 non-zero bytes as the allocator churned).
Reachability analysis (why this is filed Low and not High)
uiomovez has exactly one in-tree caller: the NFSv3 short-read
zero-fill at sys/vfs/nfs/nfs_vnops.c:1433
(uiomovez(len - retlen, uiop), len = min(tsiz, nm_rsize),
default nm_rsize = NFS_MAXDATA = 32768 β sys/vfs/nfs/nfs_vfsops.c:106,
sys/vfs/nfs/nfsproto.h:57). A malicious/compromised NFSv3 server can
legally reply count < len, eof = 0, which is precisely the zero-fill
trigger β but every uio reaching nfs_readrpc_uio() today is
UIO_SYSSPACE:
- sys/vfs/nfs/nfs_bio.c:1080-1104 (
nfs_doio):io.iov_base = bp->b_data,uio_segflg = UIO_SYSSPACEβ takes thebzero()branch (safe); - sys/vfs/nfs/nfs_vnops.c:433-448: 1-byte
UIO_SYSSPACEprobe (safe).
Therefore no stock syscall path currently reaches the OOB copyout;
the defect is a live booby-trap in a core subroutine: any caller that
zero-fills >4096 bytes into a UIO_USERSPACE uio (e.g. someone
"optimizing" nfs_doio to copy directly into user buffers) gets an
immediate 60KB-class kernel heap disclosure into unprivileged hands.
Engine defect: certain. Current reachability: latent.
Exploit chain
None (information disclosure class; no corruption primitive). The leak window is the fixed 60KB following a boot-time allocation; on this guest it holds allocator metadata (no kernel pointers observed in 4 runs). On a system whose post-ZeroPage pages hold pointer-bearing structures it would aid heap/KASLR mapping. No userβroot route.
Fix validation
- Baseline (kernel #0, stock):
run.logβ 4/4 runs LEAK CONFIRMED asnobody(64, 64, 64, 63 non-zero bytes β₯4096). - Patched (kernel #1,
fix.diffapplied,make nativekernel+make installkernel+ reboot):run.fixed.logβ 2/2 runs: all 65536 bytes zero, 0 non-zero bytes,read()semantics unchanged. - fix.diff is
git apply-clean against sys/kern/kern_subr.c.
PoC changes vs the seed
No seed existed for this finding (discovered during pass 2 of
sys/kern/kern_subr.c). Harness authored fresh; needed two fixes during
bring-up: (1) out-of-tree KLD builds require SYSDIR=/usr/src/sys in
the Makefile; (2) DragonFly devfs requires an explicit .d_open
handler or open(2) fails ENODEV.
Fix verification
fixedApplied fix.diff to /usr/src/sys/kern/kern_subr.c in-guest, make -j4 nativekernel + make installkernel + reboot into kernel #1; re-ran the identical PoC twice as nobody: 0 non-zero bytes past offset 4096 (vs 64 on baseline), read() still zero-fills the full 65536 bytes; guest stable, no panic.
["run.fixed.log: 2 runs, 'RESULT: no leak observed', 'non-zero bytes >= 4096 : 0 / 61440'", 'run.log: baseline comparison, 4 runs LEAK CONFIRMED', 'fix.diff: git-apply-clean one-hunk clamp']
Confirmed kernel references
Detail
Evidence (decisive lines)
["run.log: 4 runs as nobody, each 'RESULT: LEAK CONFIRMED', 64/64/64/63 non-zero bytes at offsets >=4096, bytes[0..4095] zero", 'leak_sample_run1/2/3.txt: identical sparse slab-metadata qwords at fixed offsets (cross-run stability = kernel memory, not noise)', 'run.fixed.log: 2 runs on PAGE_SIZE-clamped kernel #1 - 0 non-zero bytes, read still returns 65536 zeros', 'build.log: module builds -Werror clean via SYSDIR=/usr/src/sys conf/kmod.mk', 'VERDICT.md: reachability trace showing both nfs_readrpc_uio callers pass UIO_SYSSPACE (bzero branch)']
PoC changes
authored fresh (no seed); bring-up fixes: KLD Makefile needs SYSDIR=/usr/src/sys for out-of-tree builds; devfs needs explicit .d_open or open(2) returns ENODEV
Verified recommended fix
Clamp cnt to PAGE_SIZE per loop iteration in uiomovez (see fix.diff).
Verdict
uiomovez()'s UIO_USERSPACE branch copyouts cnt = min(iov_len, n) bytes from ZeroPage, but ZeroPage is exactly PAGE_SIZE (kern_slaballoc.c:316); any n > 4096 reads past the allocation into the user's buffer. KLD harness proved the primitive: 64 stable non-zero kernel-heap bytes (slab bookkeeping) delivered to an unprivileged nobody process in 4/4 runs on stock kernel #0; the one-line PAGE_SIZE clamp (fix.diff, kernel #1) eliminated all leaked bytes while preserving the zero-fill contract. Latent engine defect: the sole in-tree caller (NFSv3 short-read zero-fill, nfs_vnops.c:1433) is only ever reached with UIO_SYSSPACE uios (nfs_bio.c:1080-1117, nfs_vnops.c:433-448) which take the safe bzero() branch, so no stock syscall path triggers it today - filed Low for that reason, certain about the primitive itself.
No comments yet.