# DF-2972 — exec_shell_imgact() interpreter-line double-scan TOCTOU

## What

`sys/kern/imgact_shell.c` scans the script's first page **twice**:

- **scan 1** (`imgact_shell.c:75-96`) — counts the interpreter tokens to size
  the reservation: it feeds the `E2BIG` check (`:120`), the `bcopy` that
  shifts `argv[1..]`+env (`:123-124`), and the `endp`/`space` adjustment
  (`:126-129`).
- **scan 2** (`imgact_shell.c:138-162`) — actually copies the tokens into
  the string buffer, NUL-terminates each one and bumps `argc` (`:160`); the
  `fname` `copystr` then lands at scan 2's offset (`:169`).

The scanned page is the file's **live page-cache page** (mapped by
`exec_map_page`, `kern_exec.c:770-842`, held but perfectly writable by
anyone with a `MAP_SHARED` writable mapping). Nothing snapshots it between
the two scans, so a store landing in between makes the kernel **reserve
space for one interpreter line while copying another**.

## Why the racing writer is reachable

`exec_check_permissions` rejects concurrent writers via
`if (vp->v_writecount) return (ETXTBSY)` (`kern_exec.c:1325`), but
`v_writecount` is a count of *open-for-write descriptors*
(`kern_descrip.c:3328`, `vfs_default.c:1187`) and is dropped on
`close()` (`vfs_default.c:1213-1216`). A `MAP_SHARED` writable mapping
survives the close — the comment at `vfs_default.c:1204-1209` explicitly
acknowledges mmap writes "after the last close()". So:

```
fd = open(script, O_RDWR);
map = mmap(NULL, 128, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);                     /* v_writecount -> 0: ETXTBSY disarmed   */
/* map still writes the page-cache page exec_shell_imgact() scans      */
```

## Impact ceiling (proven on the guest, unprivileged user `maxx`)

No out-of-bounds write exists: scan 2 writes are bounded by the page
(`buf[0..4094]`, deep inside the `PATH_MAX + ARG_MAX` = 266,240-byte
`args->buf` objcache object), and `interpreter_name` stays
`MAXSHELLCMDLEN`-bounded (`:173-174`). What *does* happen, observed
userland-side via `/bin/echo` argv:

1. **fname/argv corruption** — when scan 1 < scan 2 (in total token bytes),
   the `fname` copystr and scan-2 tokens overwrite the shifted
   `argv[1..]`/env strings; when scan 1 > scan 2, a stale gap opens and
   argv strings merge across it (`run.log`: `[... /tmp/df2972/t ERARG]`,
   `[... EEEUSERARG]`).
2. **Kernel-heap disclosure into argv (leak)** — the stale gap/overlap
   bytes sit *inside* the copyout block (`exec_copyout_strings`,
   `kern_exec.c:1220`, copies `ARG_MAX - space` bytes = scan-1-sized block),
   and the fixed-count argv walk (`kern_exec.c:1231-1236`) hands pointers
   into them. Observed: **stale content of the recycled exec-args objcache
   object (a previous exec's environment strings, e.g. `K00=EEE...`) was
   printed as argv by the interpreter** (`run.log` iter=2/4,
   `run.hammer2.log` iter=0). The object is recycled across *all*
   processes' `execve`s, so on a multi-user system this is a snooping
   primitive for other users' argv/env (command-line secrets).
   Cross-user snooping was not directly demonstrated (single-user guest);
   the mechanism (stale prior-exec bytes reaching argv) was.

Reproduced on both tmpfs and hammer2 (root FS), ~100% divergence rate when
racing (mutator flips the interpreter line while a ~200 KB env widens the
scan1→scan2 `bcopy` window). No panic in any run — consistent with the
in-object bounds proof.

## Build / run / expected

```
# on the guest, as unprivileged user (maxx):
cc -O2 -pthread -Wall -o race_demo race_demo.c      # build.sh
./race_demo 40000 8                                 # run.sh
```

Expected (pass):

```
DF2972_CANONICAL_A=[AAAA /tmp/df2972/t USERARG]
DF2972_CANONICAL_B=[BBBB...(48) /tmp/df2972/t USERARG]
DF2972_HIT iter=0 out=[...]        # mangled fname/USERARG and/or stale
DF2972_HIT ...                     # prior-exec env bytes in argv
DF2972_SUMMARY ... HITS=<n>        # n >= 1
DF2972_VERDICT: RACE_DETECTED (scan1/scan2 divergence is userland-observable)
```

Guest stays up throughout (no panic expected — see bounds above).

## Fix

`fix.diff` — snapshot the first page once (`kmalloc` + `bcopy`) right after
the SHELLMAGIC/interpreted checks and point both scans at the snapshot.
Behavior-preserving for any stable page content (both loops then read
byte-identical data). Apply-checked against the guest `/usr/src` (all 6
hunks clean); kernel rebuild not performed for this non-corruption-class
fix.

## Relationship to DF-0243 (known, not re-reported)

DF-0243 (`offset -= length` size_t underflow when `argv[0]` is longer than
interp+fname) is still present at `imgact_shell.c:126` and was re-exercised
this pass (argv[0] = 256/4096/65536/262140): **no panic, script runs
normally** — confirming the prior false-positive verdict: the wrap is
equivalent modulo 2^64 to the intended signed adjustment for
`begin_envv`/`endp`, and `space` truncates back to the correct `int`.
