# 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:

```c
struct __wrusage wrusage;   /* :916  */
int status;                 /* :918  */
```

calls `kern_wait(idtype, id, &status, options, &wrusage, NULL, ...)`, then
`copyout`s both locals to userland whenever `error == 0`:

```c
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 `copyout`s 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`:

```c
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: prints `status=0x…` and a leak yes/no per iter,
  reports `N/50 leaked`.
* `wait_leak_dump.c` — sharper: hexdumps the full 144-byte `rusage` so 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 full `rusage` so the varying
  leaked bytes are visible in the evidence pack. (The original `wait_leak.c`
  was 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:

```c
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`.
