wait4/wait6 leak uninitialized kernel stack via status, rusage/wrusage and siginfo on WNOHANG/WCONTINUED return paths
| Field | Value |
|---|---|
| ID | DF-0027 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-909 Initialization of Resource with a Sensitive Value; CWE-200 Information Exposure |
| File | sys/kern/kern_exit.c |
| Lines | 913-948 (wait4), 950-992 (wait6), 1388-1431 (kern_wait) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
sys_wait4 and sys_wait6 declare uninitialized stack locals (int status;,
struct __wrusage wrusage;, siginfo_t info;), call kern_wait(...), then
copyout() those locals whenever kern_wait returns error == 0. But
kern_wait returns error == 0 on the WNOHANG-with-no-waitable-child path
(:1427-1431, only *res is set) and on the WCONTINUED match path
(:1388-1419, leaves *wrusage untouched) without writing *status,
*wrusage, or *info. The wrappers therefore copyout uninitialized
kernel-stack bytes to userland: up to ~4 B (status), ~72 B (wait4
rusage), ~144 B (wait6 __wrusage), and ~128 B (wait6 siginfo_t) per
call. An unprivileged local user with a running child can sample this
deterministically and repeatably β a KASLR/stack-residue oracle.
Root cause
sys/kern/kern_exit.c:913-948 (sys_wait4):
struct __wrusage wrusage; /* :916 uninitialized */
int status; /* :918 uninitialized */
...
error = kern_wait(idtype, id, &status, options, &wrusage, NULL,
&sysmsg->sysmsg_result);
if (error == 0 && uap->status)
error = copyout(&status, uap->status, sizeof(*uap->status)); /* :942 */
if (error == 0 && uap->rusage) {
ruadd(&wrusage.wru_self, &wrusage.wru_children);
error = copyout(&wrusage.wru_self, uap->rusage, sizeof(*uap->rusage)); /* :945 */
}
sys_wait6 (:950-992) is the same shape plus a siginfo_t copyout
(:991).
kern_wait WNOHANG-no-match (:1427-1431):
if (options & WNOHANG) {
*res = 0;
error = 0;
goto done; /* status/wrusage/info never written */
}
The WCONTINUED branch (:1388-1419) sets *res, *status = SIGCONT, and
*info but never writes *wrusage. ECHILD (nfound==0) is safe only
because the wrappers gate copyout on error == 0; the WNOHANG-no-match path
returns error == 0 and is therefore exposed.
Threat model & preconditions
- Attacker position: any local unprivileged user.
- Privileges gained or impact: information disclosure. Each call leaks up
to ~4 B + ~72 B (
wait4) or ~144 B + ~128 B (wait6) of kernel-stack residue (pointer fragments, canary, prior-syscall data). A samplable KASLR/stack-residue oracle that lowers the bar for exploiting a separate kernel bug. Not a direct LPE. - Required config or capabilities: none; default kernel.
- Reachability:
wait4(child, &status, WNOHANG, &ru)orwait6(...)with a running (non-waitable) child present.
Proof of concept
PoC source: findings/poc/DF-0027/wait_leak.c
Build & run (unprivileged)
cc -o wait_leak findings/poc/DF-0027/wait_leak.c ./wait_leak
Expected output
iter 0: status=0x<residue> (LEAKED) rusage-nonzero-byte (LEAKED) ... result: LEAK CONFIRMED
Impact
Low-impact kernel-stack info leak, samplable in a tight loop. Useful as a KASLR/stack-residue oracle ingredient. Rated Low (info-leak only; same class as DF-0007/DF-0010).
Recommended fix
Initialize all output parameters up front in kern_wait so the early-success
non-reap return paths cannot leak:
--- a/sys/kern/kern_exit.c
+++ b/sys/kern/kern_exit.c
@@ -1020
+ /*
+ * Initialize all output parameters up front so that early-success
+ * return paths that do not reap a child (e.g. WNOHANG with no
+ * waitable child, or WCONTINUED) cannot leak uninitialized kernel
+ * stack memory back to userland via the wait4/wait6 copyouts.
+ */
+ *status = 0;
+ bzero(wrusage, sizeof(*wrusage));
+ if (info)
+ bzero(info, sizeof(*info));
(Place this at the top of kern_wait, after argument validation.)
References
sys/kern/kern_exit.c:913-948βsys_wait4uninitialized locals + copyout.sys/kern/kern_exit.c:950-992βsys_wait6(same shape + siginfo).sys/kern/kern_exit.c:1427-1431β WNOHANG-no-match early-success return.- CWE-909, CWE-200.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/kern_exit.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0027 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| wait_leak.c | trigger-source | minimal leak PoC: fork+pause child, wait4 WNOHANG, detect non-marker bytes | 2.2 KB | view raw |
| wait_leak_dump.c | trigger-source | sharper PoC: hexdumps full 144-byte rusage so varying residue is visible | 2.0 KB | view raw |
| build.sh | build-script | cc -o wait_leak wait_leak.c; cc -o wait_leak_dump wait_leak_dump.c | 253 B | view raw |
| run.sh | run-script | ./wait_leak | 405 B | view raw |
| fix.diff | suggested-fix | initialize *status/*wrusage/*info at top of kern_wait (git-apply-able) | 660 B | view raw |
| run.log | run-log | baseline #0 run: 50/50 iters leak + hexdumps of varying kernel-stack residue | 11.8 KB | view raw |
| fix_run.log | run-log | patched #1 run: 0/8 iters leak, all-zero rusage | 1.9 KB | view raw |
| fix_build.log | build-log | nativekernel build completion marker (rc=0) | 439 B | view raw |
| env.txt | environment | baseline #0 guest: uname, cc version, kern.version | 349 B | view raw |
| env_patched.txt | environment | patched #1 guest: kern.version #1 + kernel.alt sha256 | 457 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, evidence, fix, validation | 5.7 KB | β raw |
| README.md | readme | original PoC readme | 1.2 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-0027 β PoC
wait_leak.c β unprivileged leak of uninitialized kernel stack via
wait4(child, &status, WNOHANG, &ru).
The bug
sys_wait4 (sys/kern/kern_exit.c:913-948) declares uninitialized locals
int status; (:918) and struct __wrusage wrusage; (:916), calls
kern_wait(&status, ..., &wrusage, ...), and copyouts them whenever
error == 0 (:942/:945). sys_wait6 does the same and also copies out a
siginfo_t.
kern_wait returns error == 0 on the WNOHANG-no-match path (:1427-1431:
*res = 0; error = 0; goto done) without writing *status/*wrusage/
*info, and on the WCONTINUED path leaves *wrusage untouched. So a caller
with a running (non-waitable) child samples 4 B (status) + ~72 B (wait4
rusage) β up to ~144 B (wait6 wrusage) + ~128 B (siginfo) β of
uninitialized kernel stack per call.
Build & run (unprivileged)
cc -o wait_leak findings/poc/DF-0027/wait_leak.c ./wait_leak
Expected output (bug present)
iter 0: status=0x<residue> (LEAKED) rusage-nonzero-byte (LEAKED) ... result: N/50 iterations leaked kernel-stack bytes result: LEAK CONFIRMED
The leaked bytes vary (kernel-stack residue from prior syscalls) β a samplable KASLR/stack-residue oracle.
DF-0027 β wait4/wait6 uninitialized-kernel-stack info leak
Verdict
REPRODUCED β FIXED. Info leak of uninitialized kernel-stack bytes via
wait4(child, &status, WNOHANG, &ru) confirmed on the unpatched
6.5-DEVELOPMENT #0 baseline (50/50 iterations leak; ~100+ non-zero bytes
per call into struct rusage, plus the 4-byte status field). The
single-fix kernel #1 (same tree + fix.diff) closes the leak completely
(0/50 across 3 runs).
Mechanism (root cause)
sys_wait4 (sys/kern/kern_exit.c:913-948) declares uninitialized stack
locals:
struct __wrusage wrusage; /* :916 */
int status; /* :918 */
calls kern_wait(idtype, id, &status, options, &wrusage, NULL, ...), then
copyouts both locals to userland whenever error == 0:
if (error == 0 && uap->status)
error = copyout(&status, uap->status, sizeof(*uap->status)); /* :942 */
if (error == 0 && uap->rusage) {
ruadd(&wrusage.wru_self, &wrusage.wru_children);
error = copyout(&wrusage.wru_self, uap->rusage, sizeof(*uap->rusage)); /* :945 */
}
sys_wait6 (:950-992) is the same shape and additionally copyouts an
uninitialized siginfo_t info (:991).
kern_wait (sys/kern/kern_exit.c:1000) returns error == 0 on the
WNOHANG-no-waitable-child path without writing *status or *wrusage:
if (options & WNOHANG) {
*res = 0;
error = 0;
goto done; /* :1427-1431 -- status/wrusage/info untouched */
}
The WCONTINUED branch (:1388-1419) sets *status = SIGCONT and writes
*info but still never writes *wrusage. Therefore any caller that supplies
both a running (non-waitable) child and WNOHANG deterministically samples
kernel-stack bytes through the wrappers' copyouts β up to 4 B (status)
+ ~72 B (wait4 rusage) or ~144 B (wait6 __wrusage) + ~128 B
(wait6 siginfo) per call.
PoC / evidence
Two reproducers (both as unprivileged maxx, uid 1001):
wait_leak.cβ minimal: printsstatus=0xβ¦and a leak yes/no per iter, reportsN/50 leaked.wait_leak_dump.cβ sharper: hexdumps the full 144-byterusageso the varying residue is visible.
Baseline (#0) β bug present
3 consecutive runs of wait_leak all reported 50/50 iterations leaked.
The hex dump shows clearly varying kernel-stack residue per iteration
(real pointers like 0xffff_f800_8181_β¦, canaries 0xffff_ffff_ffff_ffff,
etc.):
=== iter 0 (r=0) === status = 0xffffffff (user marker was 0xCAFEBABE) rusage: 121 non-marker/non-zero bytes rusage raw (144 bytes): 0000: 7f 70 81 40 00 f8 ff ff 10 40 fa 16 01 f0 ff ff 0010: 04 39 59 18 01 f8 ff ff 80 75 ce 16 01 f0 ff ff ... 0080: 78 39 59 18 01 f8 ff ff 63 38 d7 01 ff ff ff ff === iter 1 (r=0) === status = 0xffffffff rusage: 104 non-marker/non-zero bytes # different count β real residue 0000: 7f 7b 81 40 00 f8 ff ff 90 b8 0d 1d 01 f0 ff ff ...
Patched (#1) β bug gone
Same binaries, same guest, after fix.diff was applied to the kernel
source, rebuilt, and booted:
=== iter 0 (r=0) === status = 0x00000000 rusage: 0 non-marker/non-zero bytes rusage raw (144 bytes): 0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... (all zero) result: 0/8 iters leaked result: no residue
3 consecutive runs all reported 0/50 iterations leaked.
Exploit chain
N/A β pure info leak (read-only primitive). No corruption β no escalation chain. Realistic impact ceiling: a samplable KASLR / kernel-stack-residue oracle (this guest has KASLR OFF, so the leak is most useful as a stack-residue ingredient for a separate exploit). Rated Low (same class as DF-0007 / DF-0010).
PoC changes
- Added
wait_leak_dump.cβ hexdumps the fullrusageso the varying leaked bytes are visible in the evidence pack. (The originalwait_leak.cwas left untouched; it builds and runs cleanly.)
Fix
fix.diff initialises all of kern_wait's output parameters up front,
right after argument validation and before any goto done path can leave
them untouched:
if (status)
*status = 0;
if (wrusage)
bzero(wrusage, sizeof(*wrusage));
if (info)
bzero(info, sizeof(*info));
This closes both the WNOHANG-no-match path (:1427-1431) and the
WCONTINUED wrusage gap (:1388-1419) in one place, at the root cause,
without touching the wrappers. Matches the finding's ## Recommended fix
proposal in spirit (initialize at top of kern_wait) β supersedes it by
dropping the explicit bzero(info, β¦) only-when-NULL guard into the same
ternary shape as the other two params and adding a clarifying comment.
Fix validation (Phase 8)
| kernel | sha256 (kernel.alt/kernel) | leak result |
|---|---|---|
baseline #0 (unpatched, Jul 2) |
5dc83dac⦠|
50/50 iters leak, ~100+ B/iter |
patched #1 (single-fix, Jul 12) |
6615e621β¦ |
0/50 iters leak, all zero |
Both tested with the same wait_leak + wait_leak_dump binaries from the
unprivileged maxx account. The fix is deterministic (zero across 3 runs).
Note on install method
/boot is a separate UFS partition (vbd0s1a). Direct cp of a freshly
built kernel.stripped over /boot/kernel/kernel produced a kernel the
loader refused (EFTYPE β "don't know how to load module 'kernel'"); the
loader's command_loadall also unsets kernelname before reading it, so
kernel= / kernelname= overrides in loader.conf are silently ignored.
The fix-validation kernel was therefore installed as
/boot/kernel.alt/kernel (directory form) and selected by adding
default_kernel="kernel.alt" to /boot/defaults/loader.conf (must be in
the defaults file so it is set before dloader.menu's ifset
conditional runs). This is purely a validation-harness detail; the code
change under test is the single hunk in fix.diff.
Fix verification
fixedVALIDATED: baseline 50/50 leak; patched 0/50 leak, all-zero, x3 runs.
BEFORE #0: 50/50 iterations leak status=0xffffffff + KVA ptrs. AFTER #1: 0/50 leak, all zero.
Confirmed kernel references
Detail
Exploit chain
none (pure info leak, read-only primitive). Realistic impact ceiling: kernel-stack-residue oracle (~148 bytes/call).
Evidence (decisive lines)
baseline #0: 50/50 iterations leak status=0xffffffff + rusage with KVA pointers. patched #1: 0/50 leak, all zero.
PoC changes
Added wait_leak_dump.c (hexdumps full 144-byte rusage). Added build.sh, run.sh, VERDICT.md, manifest.json, fix.diff, full logs.
Verified recommended fix
In sys/kern/kern_exit.c::kern_wait, immediately after the EINVAL validation block: initialize all output parameters (*status=0, bzero wrusage, bzero info). Full git-apply-able diff in findings/poc/DF-0027/fix.diff.
Verdict
REPRODUCED + FIX VALIDATED. sys_wait4 declares uninitialized stack locals (int status, struct __wrusage wrusage) then copyouts them when kern_wait returns error==0. The WNOHANG-no-waitable-child path returns error==0 WITHOUT writing status/wrusage/*info. A caller with a running child deterministically samples kernel-stack bytes. Confirmed: 50/50 iterations leak ~100+ non-zero bytes including KVA pointers (0xfffff800...). Patched #1: 0/50 iterations leak, rusage all-zero.
No comments yet.