exec_shell_imgact() double-scan TOCTOU on the live first page: kernel reserves one interpreter line and copies another; stale recycled exec-args objcache bytes can be delivered to the interpreter as argv (kernel-heap info leak)
| Field | Value |
|---|---|
| ID | DF-2972 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-367 (CWE-909-class consequence) |
| File | sys/kern/imgact_shell.c |
| Lines | 75-96 vs 138-162 (enabler: kern_exec.c:1325 ETXTBSY fd-only) |
| Area | kern/exec |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | kernleak |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The #! interpreter line is scanned twice β scan1 (:75-96) sizes the reservation (E2BIG :120, argv/env bcopy shift :123-124, endp/space :126-129) and scan2 (:138-162) copies the tokens and bumps argc, with fname copystr landing at scan2's offset. The scanned page is the file's live page-cache page. A MAP_SHARED writable mapping that outlives close() can mutate the line between the scans: exec_check_permissions' ETXTBSY only checks v_writecount, which counts open-for-write fds and is dropped on close while the mapping persists (the vfs_default.c comment acknowledges post-close mmap writes). Reproduced on the guest as an unprivileged user with ~100% divergence while racing (tmpfs and hammer2): fname/argv strings overlap or gap, and stale prior-exec content of the recycled exec-args objcache object (a previous exec's environment strings) is inside the copyout block and handed to the interpreter as argv strings by the fixed-count walk. No OOB write is possible (scan2 writes page-bounded inside the 266,240-byte args->buf object); no panic in any run. Ceiling: unprivileged cross-process snooping of other users' exec argv/env (command-line secrets) via a raced-exec loop; the stale-objectβargv mechanism was demonstrated on the single-user guest.
Proof of contest
VERIFIED (findings/poc/DF-2972/): race_demo.c as unpriv user β script
#!/bin/echo <raced bytes>, O_RDWR + mmap MAP_SHARED RW + close
(ETXTBSY disarmed), thread A flips the token bytes, thread B fork-execs
with ~200 KB env widening the window. /bin/echo prints argv: DF2972_HIT
lines with mangled adjacency (overlap/gap) and stale prior-exec env
strings dumped into argv, 5/5 runs, 100% hit rate while racing, tmpfs +
hammer2, guest stays up. No userβroot route (not memory corruption).
Fix (snapshot the first page once, 6-hunk diff apply-checked) in the
pack.
Timeline
- 2026-09-02 Discovered during pass-2 audit of imgact_shell.c (GLM 5.3); unpriv leak reproduced 5/5. DF-0243 re-verified present but benign (prior false-positive confirmed: exercised at argv[0]=256/4096/65536/262140 β no panic).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2972 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.8 KB | β raw | |
| VERDICT.md | β | 6.1 KB | β raw | |
| race_demo.c | β | 8.0 KB | view raw | |
| build.sh | β | 160 B | view raw | |
| run.sh | β | 221 B | view raw | |
| build.log | β | 9 B | view raw | |
| run.log | β | 8.9 KB | view raw | |
| run.2.log | β | 8.6 KB | view raw | |
| run.3.log | β | 12.6 KB | view raw | |
| run.hammer2.log | β | 4.5 KB | view raw | |
| leak_sample.txt | β | 17.3 KB | view raw | |
| env.txt | β | 347 B | view raw | |
| fix.diff | β | 2.3 KB | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.9 KB | view raw |
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 theE2BIGcheck (:120), thebcopythat shiftsargv[1..]+env (:123-124), and theendp/spaceadjustment (:126-129). - scan 2 (
imgact_shell.c:138-162) β actually copies the tokens into the string buffer, NUL-terminates each one and bumpsargc(:160); thefnamecopystrthen 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:
- fname/argv corruption β when scan 1 < scan 2 (in total token bytes),
the
fnamecopystr and scan-2 tokens overwrite the shiftedargv[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]). - Kernel-heap disclosure into argv (leak) β the stale gap/overlap
bytes sit inside the copyout block (
exec_copyout_strings,kern_exec.c:1220, copiesARG_MAX - spacebytes = 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.logiter=2/4,run.hammer2.logiter=0). The object is recycled across all processes'execves, 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.
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
Bs β 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)
exec_map_firstpageβexec_map_page(kern_exec.c:770-842) maps the file's page-cache page 0 into KVA vialwbuf; it is held (vm_page_hold) but writable by a surviving shared mapping.exec_check_permissionsETXTBSY (kern_exec.c:1325) sees onlyv_writecountβ zero afterclose()(kern_descrip.c:3328bumps on open,vfs_default.c:1213-1216drops on close; comment at:1204-1209acknowledges post-close mmap writes).- scan 1 (
imgact_shell.c:75-96) sizesoffset; E2BIG check:120;bcopyshifts argv[1..]+env byoffset-length:123-124;endp,spaceupdated:126-129. - scan 2 (
:138-162) copies whatever the line says NOW, bumpsargc:160;fnamecopystr lands at scan-2's offset:169-170. - 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-2token bytes + NULs β confined tobuf[0..~4094], inside thePATH_MAX + ARG_MAX= 266,240-byte object (kern_exec.c:137-138,sys/sys/syslimits.h:47). interpreter_namecopystr bounded byMAXSHELLCMDLEN= 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
fnameslot atbuf+ARG_MAXguarantee 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).
Fix verification
not_testablefix.diff (page snapshot, behavior-preserving for stable content) authored after verification; apply-checked against guest /usr/src with 'patch --dry-run -p1' -- all 6 hunks clean (APPLY_CHECK_OK). Kernel rebuild and patched-behavior rerun not performed: non-corruption-class finding, fix validation not mandated for it; snapshot approach trivially removes the second read of the mutable page.
['fix.diff', "VERDICT.md section 'Fix' (apply-check result)"]
Confirmed kernel references
- sys/kern/imgact_shell.c:75
- sys/kern/imgact_shell.c:120
- sys/kern/imgact_shell.c:123
- sys/kern/imgact_shell.c:126
- sys/kern/imgact_shell.c:138
- sys/kern/imgact_shell.c:160
- sys/kern/imgact_shell.c:169
- sys/kern/imgact_shell.c:173
- sys/kern/kern_exec.c:770
- sys/kern/kern_exec.c:1220
- sys/kern/kern_exec.c:1231
- sys/kern/kern_exec.c:1325
- sys/kern/kern_exec.c:137
- sys/kern/vfs_default.c:1213
- sys/kern/kern_descrip.c:3328
Detail
Exploit chain
unpriv user: (1) create #!/bin/echo script; (2) open O_RDWR, mmap MAP_SHARED RW, close fd (ETXTBSY disarmed); (3) thread flips interpreter-line bytes while exec'ing the script with ~200KB env to widen the scan1->scan2 bcopy window; (4) scan1!=scan2 => fname/argv overlap or stale gap inside the copyout block; (5) interpreter prints argv incl. stale recycled-object strings (prior execs' argv/env) -- loop to snoop. No escalation: not memory corruption (all writes in-object).
Evidence (decisive lines)
['run.log: DF2972_HIT iter=3 out=[... /tmp/df2972/t ERARG] (overlap destroys USERARG prefix)', 'run.log: DF2972_HIT iter=1 out=[... EEEUSERARG] (stale gap merges strings)', 'run.log: DF2972_HIT iter=2 out=[... /tmp/df2972/t 00=EEEE...] (stale prior-exec env string K00=EEE... from recycled exec-args object delivered as argv)', 'run.hammer2.log: same on hammer2 root FS (not tmpfs-specific)', 'VERDICT.md: full mechanism with path:line and bounds proof', 'leak_sample.txt: stale-byte samples across 4 runs']
PoC changes
written fresh this pass (no prior seed): race_demo.c -- close-after-mmap bypasses ETXTBSY; mutator flips interpreter-arg bytes 12..59 between 1-token/49-byte and 1-token/5-byte states; ~200KB env widens the scan1->scan2 bcopy window; divergence-strict detector (fname/USERARG adjacency) avoids false positives from mid-flip single-state views; plus a hammer2-root-FS variant run.
Verified recommended fix
Snapshot the mapped first page once (kmalloc+bcopy) after the interpreted check and run both the accounting and copy passes against the snapshot; see fix.diff.
Verdict
exec_shell_imgact() scans the script's live page-cache first page twice (accounting at sys/kern/imgact_shell.c:75-96, copy at :138-162). A MAP_SHARED writable mapping that outlives close() can mutate the interpreter line between the scans -- exec_check_permissions' ETXTBSY (kern_exec.c:1325) only counts open-for-write fds (vfs_default.c:1213-1216 drops v_writecount on close while the mapping persists). Reproduced as an unprivileged user with ~100% divergence rate while racing (tmpfs and hammer2): the kernel reserved space for one interpreter line and copied another, corrupting fname/argv placement inside the 266,240-byte args->buf object (no OOB write possible: scan-2 writes bounded by the page to buf[0..4094]; interpreter_name stays MAXSHELLCMDLEN-bounded) and exposing stale prior-exec content of the recycled exec-args objcache object as interpreter argv strings (kernel-heap bytes inside the copyout block at kern_exec.c:1220 handed out by the argv walk at kern_exec.c:1231-1236). No panic in any run; guest stayed up. Realistic ceiling: unprivileged cross-process snooping of other users' exec argv/env via a raced-exec loop; cross-user snooping itself not demonstrated on the single-user guest.
No comments yet.