# DF-2882 VERDICT — REPRODUCED (impact: leak)

## Root cause (path:line)

`sys/kern/subr_scanf.c:289-306`, CT_CHAR suppress branch:

```c
if (flags & SUPPRESS) {
    size_t sum = 0;
    for (;;) {
        if ((n = inr) < width) {      /* :292  short input */
            sum += n;
            width -= n;
            inp += n;                 /* :295  inp -> NUL terminator */
                                       /*       inr NOT zeroed! */
            if (sum == 0)
                goto input_failure;
            break;
        } ...
```

`inp` advances by `n = inr` (to the terminator) while `inr` keeps its
old value. Every consumer of the invariant "`inr > 0` ⇒ `*inp` is a
valid unread char" then operates on phantom input:

- guard at :258 (`if (inr <= 0) goto input_failure`) passes;
- `%s` loop at :375-382 accepts the NUL (`!isspace('\0')`), copies it,
  advances `inp` past the terminator and keeps copying until whitespace
  or `inr`/width exhausts — up to `inr-1` bytes past the string end;
- `%c` at :308 `bcopy`s `width` bytes starting at the terminator;
- negated `%[^...]` at :324 matches the NUL (`tab[0]=1`) and walks.

The alternative branch (`inr -= width; inp += width`) is balanced;
CCL/STRING/INT loops all decrement `inr` in lockstep with `inp++`.
Only this branch loses the count — and only when `width > inr`
(positive control E6, `width == inr`, is clean).

## How it was proven

1. **Verbatim userland replica** (guest libc as control):
   `%*16c%s` on an 8-char string → engine `ret=1`,
   `out = 00 'C' 'A' 'N' 'A' 'R' 'Y' '!' 00`; libc `ret=0`.
2. **In-kernel, kernel's own ksscanf** (stock INVARIANTS kernel #0,
   KLD harness, run.log):
   - E1: canary bytes after the NUL copied into caller buffer.
   - E2: `kmalloc(32)` exact-size input, groomed neighbor at `in+32`
     (`delta199=32`): `out = 00 51×15` — 'Q' bytes from **beyond the
     allocation**, i.e. out of the adjacent *live* heap object.
     Cross-object kernel heap disclosure.
   - E3: the walk crossed slab metadata: `out = 00 80 16 94 8d 00 f8
     ff ff 51 ...` — a **live kernel pointer** (0xfffff8008d941680)
     plus heap bytes copied into the caller buffer. Second load
     leaked 0xfffff8004f102660 (run.2.log) — two distinct pointers,
     consistent with slab freelist/metadata adjacency.
   - E6 negative control (`%*8c`, N == strlen): clean.
3. **Fix validation** (kernel #1 built in-guest with fix.diff hunk 2):
   E1/E2/E3 → `ret=0`, `out` untouched (sentinel 0xee intact) — the
   walk is gone; behavior now equals libc.

## Impact ceiling

- Kernel memory disclosure: bytes past the end of the input string
  (same allocation = semantic break; end of allocation = adjacent
  kernel heap objects, slab metadata, kernel pointers) copied into the
  caller's destination buffer — buffers that callers overwhelmingly
  copy back to userland or embed in further output.
- Potential panic/DoS if the walk crosses into an unmapped page
  (walk length ≤ phantom `inr` ≤ strlen).
- Return-value/nassigned corruption (conversion "succeeds" on
  exhausted input).

## Why Medium (not High)

No in-tree caller currently emits `%*Nc` with N>1 (21 call sites
surveyed: radeon_vce.c:107,122; ttm_page_alloc.c:167; ttm_memory.c:122;
amdgpu_gfx.c:126; vinumio.c:711 [uses %*d/%*[a-z]/%*[s] — different,
balanced paths]; if_ath.c:576 [%*c width 1 — balanced]; if_mxge.c:625;
smc_sysctl.c:589; channel.c:1078; feeder_eq.c:512; if_ethersubr.c:1690;
pci.c:3657,3661; autoconf.c:239,263,287; kern_uuid.c:394;
vfs_conf.c:431; vkernel64 autoconf.c:316,340,364). The format string
is kernel-supplied, so an unprivileged user cannot trigger it through
any shipping code path. It is a live defect of the exported KPI that
any KLD or future caller invokes at its peril — filed Medium in the
kernleak bucket so the one-line fix lands.

## Exploit chain

None in-tree (no `%*Nc` caller). For a hypothetical caller with
user-controlled input: `%*Nc` + `%s` on an exact-size heap string
→ adjacent-object kernel heap bytes (incl. pointers) copied into the
caller buffer → returned to user. No write primitive: all writes stay
inside caller-supplied destination buffers.

## verdict.json summary

status=reproduced, reproduced=1, impact=leak, confidence=certain,
fix_status=fixed (kernel #1, in-guest nativekernel, baseline
reproduced / patched clean).
