β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2972

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2972 Β· 15 files
FileTypeDescriptionSize
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 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' 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.

VERDICT.md
↓ download raw

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)

  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).

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.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)"]
↓ fix.diffper-fix-DF-2972

Confirmed kernel references

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.