# DF-2972 — VERDICT: REPRODUCED (TOCTOU divergence + kernel-heap disclosure into argv; no memory unsafety)

## One-line verdict

The double scan of the script's live first page in `exec_shell_imgact()`
(scan 1 = accounting at `sys/kern/imgact_shell.c:75-96`, scan 2 = copy at
`:138-162`) is racy: a `MAP_SHARED` writable mapping that outlives
`close()` can change the interpreter line between the two scans (ETXTBSY
cannot fence it — `v_writecount` drops on close,
`sys/kern/vfs_default.c:1213-1216`). Reproduced on the guest as an
unprivileged user with a **~100% divergence rate** while racing: the
kernel reserved space for one interpreter line and copied another,
corrupting fname/argv placement **inside** the 266,240-byte `args->buf`
object and exposing **stale prior-exec content of the recycled exec-args
objcache object as argv strings** of the interpreter. No OOB write is
possible (scan 2 writes bounded by the page to `buf[0..4094]`;
`interpreter_name` still `MAXSHELLCMDLEN`-bounded at `:173-174`); no panic
occurred in any run.

## Reproduction (guest `6.5-DEVELOPMENT #0`, Thu Jul 2 06:02:54 UTC 2026)

`race_demo.c` — creates `/tmp/df2972/t` (`#!/bin/echo <48 raced bytes>`),
keeps a writable shared mapping after close, one thread flips bytes 12..59
between state A (`AAAA` + 44 spaces → 1 token, 5 bytes) and state B (48
`B`s → 1 token, 49 bytes), while the main thread forks execs of the script
with `argv = {script, "USERARG"}` and a ~200 KB environment (widening the
scan1→scan2 `bcopy` window at `:123-124` to tens of µs).

Detection is divergence-strict: for any single (even mid-flip) page state
observed consistently by both scans, `/bin/echo`'s output keeps
`/tmp/df2972/t USERARG` adjacent and intact. Any output violating that is
a scan1≠scan2 divergence.

Four runs (plus a hammer2-root-FS variant), all positive, guest stayed up:

| run                 | iterations | hits | note                                            |
|---------------------|-----------|------|-------------------------------------------------|
| first session run   | 8         | 8    | overlap (`ERARG`), gap (`EEEUSERARG`), stale env dumps |
| `run.log`           | 6         | 6    | decisive full-capture run                       |
| `run.2.log`         | 4         | 4    | stability                                       |
| `run.3.log`         | 4         | 4    | stability                                       |
| `run.hammer2.log`   | 4         | 4    | script on hammer2 `/` — not tmpfs-specific (`RG`=mangled USERARG) |

Decisive samples (`run.log` + session capture, in `leak_sample.txt`):

```
DF2972_HIT iter=3 out=[AAAA BBBBBBBBBB BBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBB /tmp/df2972/t ERARG]
    ^ overlap case: scan2 tokens+fname overwrote the shifted argv — "US" of USERARG destroyed
DF2972_HIT iter=1 out=[AAAA B BBBBBBBBBBBBBB BB BBBBBBBBB /tmp/df2972/t EEEUSERARG]
    ^ gap case: stale bytes between fname and USERARG — strings merged across the gap
DF2972_HIT iter=2 out=[BBABBBB... /tmp/df2972/t 00=EEEEEEE... (≈4KB)]
    ^ LEAK: stale content of the recycled exec-args objcache object (prior
      exec's environment string "K00=EEE...") delivered as argv strings
```

## Mechanism (path:line)

1. `exec_map_firstpage` → `exec_map_page` (`kern_exec.c:770-842`) maps the
   file's **page-cache page 0** into KVA via `lwbuf`; it is held
   (`vm_page_hold`) but writable by a surviving shared mapping.
2. `exec_check_permissions` ETXTBSY (`kern_exec.c:1325`) sees only
   `v_writecount` — zero after `close()` (`kern_descrip.c:3328` bumps on
   open, `vfs_default.c:1213-1216` drops on close; comment at
   `:1204-1209` acknowledges post-close mmap writes).
3. scan 1 (`imgact_shell.c:75-96`) sizes `offset`; E2BIG check `:120`;
   `bcopy` shifts argv[1..]+env by `offset-length` `:123-124`; `endp`,
   `space` updated `:126-129`.
4. scan 2 (`:138-162`) copies whatever the line says NOW, bumps `argc`
   `:160`; `fname` copystr lands at scan-2's offset `:169-170`.
5. scan1 ≠ scan2 ⇒ fname/shifted-strings overlap or gap ⇒ stale gap bytes
   inside the copyout block (`ARG_MAX - space`, `kern_exec.c:1220`) are
   handed to the interpreter as argv strings by the fixed-count walk
   (`kern_exec.c:1231-1236`).

## Bounds proof (why no OOB / no panic)

- scan 2 writes ≤ `PAGE_SIZE-2` token bytes + NULs ⇒ confined to
  `buf[0..~4094]`, inside the `PATH_MAX + ARG_MAX` = 266,240-byte object
  (`kern_exec.c:137-138`, `sys/sys/syslimits.h:47`).
- `interpreter_name` copystr bounded by `MAXSHELLCMDLEN` = 128
  (`imgact_shell.c:173-174`, `sys/sys/imgact.h:38,61`).
- The argv walk is NUL-terminated within the object (prior-exec strings +
  the `fname` slot at `buf+ARG_MAX` guarantee NULs); no walk-off.
- Observed: zero panics across ~30 raced execs in 5 runs; guest `up`.

## Exploit chain

No escalation chain — not memory corruption. Realistic ceiling is an
unprivileged cross-process snooping loop: hammer raced execs and inspect
the interpreter's argv for stale strings from the recycled exec-args
object, which on a multi-user system can contain other users'
command-line/environment secrets. (Cross-user snooping not directly
demonstrated on this single-user guest; the stale-object→argv mechanism
was demonstrated.)

## Fix

`fix.diff` (authored after verification, never applied to `sys/`):
snapshot the first page once (`kmalloc(PAGE_SIZE, M_TEMP, M_WAITOK)` +
`bcopy`) right after the `interpreted` check and point both scans at the
snapshot; free on all exits. Behavior-preserving for stable content.
Apply-checked against guest `/usr/src`: all 6 hunks clean
(`patch --dry-run -p1` → `APPLY_CHECK_OK`). Kernel rebuild + patched-behavior
rerun not performed (non-corruption class; per-run scope) — hence
`fix_status = not_testable`.

## DF-0243 cross-check (known finding, not re-reported)

Still present at `imgact_shell.c:117-129`; re-exercised at argv[0] =
256/4096/65536/262140: `/bin/sh` ran normally every time, no panic, guest
up — consistent with the existing false-positive verdict (two's-complement
wrap ≡ intended signed adjustment for `begin_envv`/`endp`; `space`
truncates back to the correct `int`).
