# DF-2558 — VERDICT

## Verdict
**REPRODUCED** — uninitialized kernel-stack info leak via the AF_UNIX
`pcblist` sysctl handler (`unp_pcblist` in `sys/kern/uipc_usrreq.c`). The
sysctl is world-readable (`CTLFLAG_RD`, no privilege check), so any
unprivileged local user can sample it. **Fix VALIDATED** on a built-and-
booted single-fix kernel: the leak is gone (0 leaked bytes across 3×3 runs).

## Mechanism (trigger → primitive → effect)

1. **Trigger** — an unprivileged user creates a few AF_UNIX sockets (so the
   pcblist contains records) and reads one of the world-readable pcblist
   sysctl nodes:
   - `net.local.dgram.pcblist`
   - `net.local.stream.pcblist`
   - `net.local.seqpacket.pcblist`
   (`sys/kern/uipc_usrreq.c:1511-1519`). These are `SYSCTL_PROC(...,CTLFLAG_RD,...)`
   with no `priv_check`/`suser` gate; `netstat`/`fstat` rely on them.

2. **Primitive (root cause)** — `unp_pcblist()` (`sys/kern/uipc_usrreq.c:1464-1502`)
   walks every PCB in the head list and, **for each one**, declares the
   912-byte response record on the stack with **no initializer**:
   ```c
   /* uipc_usrreq.c:1465 */
   while ((unp = TAILQ_NEXT(marker, unp_link)) != NULL && i < n) {
       struct xunpcb xu;          /* <-- UNINITIALIZED on-stack, 912 B */
       ...
   ```
   It then writes only:
   - `xu.xu_len = sizeof(xu)`                                (uipc_usrreq.c:1475)
   - `xu.xu_unpp = unp`                                       (uipc_usrreq.c:1476)
   - `bcopy(unp->unp_addr,  &xu.xu_addr,  sun_len)`  partial fill of a **256-byte union** (uipc_usrreq.c:1486)
   - `bcopy(conn->unp_addr, &xu.xu_caddr, conn_sun_len)` partial fill of a **256-byte union** (uipc_usrreq.c:1490)
   - `bcopy(unp, &xu.xu_unp, sizeof(*unp))`  full struct unpcb copy   (uipc_usrreq.c:1494)
   - `sotoxsocket(so, &xu.xu_socket)`  full xsocket fill              (uipc_usrreq.c:1495)

   Three regions of the struct are **never** written and stay as raw stack:
   - the tail of `xu.xu_addr`  union past `sun_len`  (up to 256 B)
   - the tail of `xu.xu_caddr` union past `conn_sun_len` (up to 256 B; for an
     unconnected socket, the **entire** 256 B since nothing is copied)
   - `xu_alignment_hack` (8 B trailer at the end of `struct xunpcb`,
     `sys/sys/unpcb.h:126`) — **never** written by the handler.

3. **Effect — kernel stack info leak** — `SYSCTL_OUT(req, &xu, sizeof(xu))`
   (`uipc_usrreq.c:1498`) copies the whole 912-byte struct verbatim to
   userspace, including the uninitialized tail. The comment on line 1497
   ("This could block and temporarily release unp_token") means the stack
   slot is reused between records, so each record carries fresh residue.

   Per record up to **~510 bytes** of pure kernel-stack residue leak; for a
   bound, unconnected dgram socket (sun_len≈18) the measured leak is
   ~290–340 non-zero bytes. `xu_alignment_hack` reliably leaks the canonical
   kernel-virtual pointer `0xffffffff810e8790` in every record of every run
   (CWE-908 uninitialized-stack disclosure / CWE-200 info exposure).

## Reproduction evidence (unpatched `#0` baseline)

`run.log`, `run.2.log`, `run.3.log` — three standalone runs of `./poc 3`
(3 sysctl reads each), excerpt:
```
=== iter 0: 10944 bytes / 12 records (recsize=912) ===
  rec 0 (xu_unpp=0xfffff8008edf0660 sun_path="/var/run/log"):
    xu_alignment_hack            (8 B): 90870e81 ffffffff
    >>> rec 0 leaked = 329 / 506 possible
  ...
==== SUMMARY over 3 iters, 39 records: 12375 leaked non-zero bytes (of 18972 possible) ====
result: LEAK CONFIRMED (kernel-stack residue in xunpcb via pcblist sysctl)
```
Run-to-run totals vary (11268, 12049, 12375) — genuine stack-residue
variation, not deterministic struct contents. The residue contains many
`0xfffff8??_????????` and `0xffffffff_????????` qwords (recognizable kernel
pointer fragments → KASLR-defeat / stack-residue oracle).

## Why it is NOT a write primitive / no escalation chain

`unp_pcblist` is a **read-only** sysctl handler: it only copies bytes OUT to
userspace and writes no attacker-controlled data into the kernel. There is
no write/UAF/double-free/type-confusion path derivable from it. **Read-only
info leak is a valid hard blocker** for an escalation chain (Phase 6 valid
blocker #1: "primitive is genuinely read-only"). Impact ceiling:
KASLR-defeat / kernel-stack-residue oracle that would aid a *separate*
write-capable bug. The leak itself is the finding (Medium).

## Fix

`fix.diff` — zero-initialize the on-stack record before filling it:
```diff
 	while ((unp = TAILQ_NEXT(marker, unp_link)) != NULL && i < n) {
 		struct xunpcb xu;
+		bzero(&xu, sizeof(xu));
 
 		TAILQ_REMOVE(&head->list, marker, unp_link);
```
This is a one-line, root-cause fix that closes every uninitialized region
(`xu_addr`/`xu_caddr` tails + `xu_alignment_hack`) with a single `bzero`.
It is the same pattern used to fix the sibling DF-2557 (SO_PASSCRED
`cmsgcred`) leak, and matches the finding markdown's `bzero`/`={}` intent.
`git apply --check -p1` passes.

## Fix validation (Phase 8)

| Kernel | `kern.version` | sha256 (/boot/kernel/kernel) | Result |
|--------|----------------|------------------------------|--------|
| baseline (unpatched) | `6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026` | (snapshot baseline) | **LEAK** — 11268–12375 non-zero bytes / 3 iters |
| single-fix            | `6.5-DEVELOPMENT #1: Sat Aug 8 23:50:52 UTC 2026` | `4ae87806a10a1d54cbd206c6fdb54812677d7d9b33ff84e416bac112e1647a1f` | **NO LEAK** — 0 non-zero bytes across 3×3 runs |

The single-fix kernel was built with `make -j6 nativekernel
KERNCONF=X86_64_GENERIC` from the patched `/usr/src`, installed with
`make installkernel KERNCONF=X86_64_GENERIC`, and booted. The `kern.version`
`#N` suffix bumped `#0` → `#1` with today's build timestamp, confirming the
patched kernel is the one running.

On the patched kernel the same PoC now prints, for every record:
```
  rec 0 leaked = 0 / 506 (clean) (al=14 cl=0)
  ...
==== SUMMARY over 3 iters, 36 records: 0 leaked non-zero bytes (of 18060 possible) ====
result: NO LEAK (xunpcb trailing bytes all zero - bug not present / fixed)
```
`xu_alignment_hack` is now `00000000 00000000` (the `bzero` killed the
leak). **`fix_status = fixed`.**

## PoC changes

Authored the PoC from scratch (`poc.c`) — the `findings/poc/DF-2558/`
folder was empty. The PoC: plants 8 bound AF_UNIX SOCK_DGRAM sockets (so the
dgram pcblist has records whose `xu_caddr`/`xu_alignment_hack` are pure
uninitialized stack), reads `net.local.dgram.pcblist` via
`sysctlnametomib`+`sysctl(2)`, walks each 912-byte `struct xunpcb` record,
and counts non-zero bytes in the three leak regions (`xu_addr` tail, the
whole/remaining `xu_caddr` union, and `xu_alignment_hack`). Uses the real
`<sys/unpcb.h>` struct (sizeof=912 on x86_64) with `offsetof` for the
region bounds. Reports per-record and per-iteration totals; exits 0 with
`LEAK CONFIRMED` if any residue is found, else exits 1 with `NO LEAK`.

## Files

| File | Purpose |
|------|---------|
| `poc.c` | trigger PoC (unprivileged) |
| `build.sh` / `run.sh` | exact build & run |
| `build.log` | unpatched-kernel build output |
| `run.log` / `run.2.log` / `run.3.log` | unpatched baseline runs (3× variance) |
| `fix_run.log` / `fix_run.2.log` / `fix_run.3.log` | patched-kernel runs (determinism) |
| `fix_build.log` | full single-fix kernel build output |
| `leak_sample.txt` | raw leaked hex + variance across baseline runs |
| `env.txt` | guest environment (uname, cc, sysctls, readability) |
| `fix.diff` | git-apply-able fix |
| `manifest.json` | artifact catalog |
