size_t underflow in exec_shell_imgact when argv[0] longer than interpreter+fname -> kernel panic
| Field | Value |
|---|---|
| ID | DF-0243 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-191 Integer Underflow |
| File | sys/kern/imgact_shell.c |
| Lines | 117-129 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-30 |
| Reported | pending |
Summary
When executing a #! script, exec_shell_imgact() adjusts the argument
buffer by computing offset -= length (:126) where both are size_t
(unsigned). offset is the total size of interpreter tokens + script
filename + NULs (~15 bytes for /bin/sh /tmp/s). length is
strlen(argv[0]) + 1. The user controls argv[0] independently of the
script path via execve(). When argv[0] is longer than the interpreter
path + filename, offset < length and the unsigned subtraction wraps to
~SIZE_MAX, corrupting begin_envv, endp, and space (:127-129).
This causes an immediate kernel panic on the subsequent pointer
dereference. Triggerable by any unprivileged local user.
Root cause
sys/kern/imgact_shell.c:117-129:
offset += strlen(imgp->args->fname) + 1; // :117 β add fname
length = strlen(imgp->args->begin_argv) + 1; // :118 β argv[0] length
if (offset > imgp->args->space + length) // :120 β E2BIG check
return (E2BIG);
bcopy(begin_argv + length, begin_argv + offset,
endp - (begin_argv + length)); // :123-124
offset -= length; // :126 β UNDERFLOW
imgp->args->begin_envv += offset; // :127 β CORRUPTED
imgp->args->endp += offset; // :128 β CORRUPTED
imgp->args->space -= offset; // :129 β CORRUPTED
Example with #!/bin/sh script at /tmp/s and argv[0] = 256 'A's:
- offset = 8 (interpreter) + 7 (fname) = 15
- length = 257
- offset -= length = 15 - 257 β wraps to SIZE_MAX - 241
- begin_envv += SIZE_MAX-241 β wild pointer
- endp += SIZE_MAX-241 β wild pointer
The E2BIG guard at :120 cannot prevent this: 15 > 262144 + 257 is false,
so the check passes.
Threat model & preconditions
- Attacker position: Any unprivileged local user.
- Impact: Kernel panic (reliable local DoS). Potential kernel memory corruption if the wrapped pointers land on mapped memory during the subsequent exec copy operations.
- Required config: Default kernel. Any filesystem with executable scripts.
- Reachability:
execve("/path/to/script", [long_argv0], envp).
Proof of concept
PoC source: findings/poc/DF-0243/
#include <unistd.h>
#include <string.h>
int main(void) {
char ao[256];
memset(ao, 'A', sizeof(ao)-1);
ao[sizeof(ao)-1] = '\0';
char *argv[] = { ao, NULL };
char *envp[] = { NULL };
/* /tmp/s contains: #!/bin/sh\necho hi */
execve("/tmp/s", argv, envp);
_exit(1);
}
Expected output
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xffff... (corrupted begin_envv/endp) panic: page fault
Recommended fix
Handle the shrink case (offset < length) explicitly:
--- a/sys/kern/imgact_shell.c
+++ b/sys/kern/imgact_shell.c
@@ -123,11 +123,19 @@
bcopy(imgp->args->begin_argv + length, imgp->args->begin_argv + offset,
imgp->args->endp - (imgp->args->begin_argv + length));
- offset -= length; /* calculate actual adjustment */
- imgp->args->begin_envv += offset;
- imgp->args->endp += offset;
- imgp->args->space -= offset;
+ if (offset >= length) {
+ size_t net = offset - length;
+ imgp->args->begin_envv += net;
+ imgp->args->endp += net;
+ imgp->args->space -= (int)net;
+ } else {
+ size_t net = length - offset;
+ imgp->args->begin_envv -= net;
+ imgp->args->endp -= net;
+ imgp->args->space += (int)net;
+ }
Timeline
- 2026-06-30 Discovered during automated audit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0243 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | original trigger: exec #!/bin/sh with long argv[0] (empty envp) | 2.1 KB | view raw |
| trigger2.c | trigger-source | NEW: large-environment trigger stressing the bcopy at imgact_shell.c:123-124 | 3.0 KB | view raw |
| setup.sh | setup-script | creates /tmp/df0243_s (#!/bin/sh) | 225 B | view raw |
| setup2.sh | setup-script | NEW: creates /tmp/df0243_s2 (sentinel-checking #!/bin/sh) | 628 B | view raw |
| math_proof.c | trigger-source | arithmetic proof that the size_t wrap == intended signed op for all 3 fields | 3.0 KB | view raw |
| build.sh | repro-script | exact build commands | 639 B | view raw |
| run.sh | repro-script | exact run command (math proof + argv0 sweep + large-env stress) | 810 B | view raw |
| build.log | build-log | full final build (clean) | 418 B | view raw |
| run.log | run-log | baseline (#0) decisive run: argv0 sweep + large-env bcopy stress, no panic | 3.0 KB | view raw |
| fix_build.log | build-log | full single-fix kernel build (make -j6 nativekernel, rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | single-fix kernel (#1) run: identical to baseline, no panic | 1.8 KB | view raw |
| fix.diff | suggested-fix | defense-in-depth signed-safe rewrite of imgact_shell.c:126-129 (validated, optional hardening) | 1.2 KB | view raw |
| env.txt | environment | uname, cc version, kern.argmax | 339 B | view raw |
| VERDICT.md | verdict | full narrative: false-positive mechanism + hardening patch validation | 10.6 KB | β raw |
| README.md | readme | human-readable evidence-pack index | 4.3 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0243 β PoC evidence pack
Finding: alleged size_t underflow in exec_shell_imgact when argv[0] is
longer than interpreter + fname β claimed kernel panic.
File: sys/kern/imgact_shell.c:117-129
Severity claimed: High (local DoS / kernel panic).
Result: NOT REPRODUCED β FALSE POSITIVE
The size_t subtraction at imgact_shell.c:126 does wrap when
strlen(argv[0]) > strlen(interp) + strlen(fname), but the wrap is
mathematically equivalent to the intended signed subtraction:
begin_envv/endparechar *β addingSIZE_MAX β Kto a pointer is identical (mod 2βΆβ΄) to subtractingK+1, which is the correct intended delta. The pointer lands safely inside theARG_MAX + PATH_MAXargs buffer.spaceisintβ(int)((size_t)space β wrapped_offset)truncates back to the correct value because|length β offset| β€ ARG_MAX + PAGE_SIZE βͺ INT_MAX.- The
bcopyat:123-124is safe in the shrink branch (content only moves left, always within the buffer); the E2BIG guard at:120correctly targets the grow branch.
Result: no wild pointer, no memory corruption, no panic. The script runs
/bin/sh /tmp/df0243_s normally and prints DF0243_SCRIPT_RAN. This holds even
with a large environment (trigger2) forcing the bcopy to shift real
bytes β the sentinel env var survives intact.
See VERDICT.md for the full line-by-line mechanism walkthrough.
A defense-in-depth hardening fix.diff (signed-safe rewrite of :126-129)
was authored and validated on a single-fix kernel (#1): it compiles, boots,
and is behaviorally identical to the original. It is optional cleanup, not a
security fix.
How to reproduce
./build.sh # cc trigger/trigger2/math_proof, create the #!/bin/sh scripts
./run.sh # math proof + argv[0] length sweep + large-env bcopy stress
Expected on the audited master DEV kernel (and on the single-fix kernel)
=== math proof === begin_envv 0x1016 0x1016 0x1016 ALL MATCH endp 0x1016 0x1016 0x1016 ALL MATCH space 262122 262122 262122 ALL MATCH => the size_t wrap is mathematically equivalent to the signed op. === argv[0] length = 256 (and 1024, 4096, 32768, 131072, 262140) === DF0243_SCRIPT_RAN DF0243_NO_PANIC: script executed normally -- underflow did not crash === large-env bcopy stress === DF0243_ENV_OK argv=0 sentinel_intact
No panic at any length or env size. The guest stays up. No kernel warnings.
Optional defense-in-depth fix
fix.diff rewrites the 4 modular-arithmetic lines at :126-129 as an explicit
grow/shrink branch with no size_t underflow. Validated:
- git apply --check β ; patch -p1 in-guest β (hunk at line 123)
- make -j6 nativekernel β rc=0 ; make installkernel β
- boots to 6.5-DEVELOPMENT #1 clean; run.sh + trigger2 behave identically
This supersedes the finding markdown's ## Recommended fix proposal (same
approach, cleaner variable names + justifying comment). Since the cited code is
not actually vulnerable, the patch is optional hardening, not a required
security fix.
Files
| File | Purpose |
|---|---|
trigger.c |
original trigger (empty envp) |
trigger2.c |
new: large-environment bcopy-stress trigger |
setup.sh/setup2.sh |
create the #!/bin/sh scripts |
math_proof.c |
arithmetic proof that wrap == signed op for all 3 fields |
build.sh/run.sh |
exact build/run commands |
build.log |
full final build (clean) |
run.log |
baseline (#0) decisive run (argv0 sweep + large-env stress) |
fix_build.log |
full single-fix kernel build (rc=0) |
fix_run.log |
single-fix kernel (#1) run (identical to baseline) |
fix.diff |
defense-in-depth signed-safe rewrite (validated, optional) |
env.txt |
guest environment |
VERDICT.md |
full narrative analysis |
manifest.json |
machine-readable catalog |
DF-0243 β VERDICT: NOT REPRODUCED / FALSE POSITIVE (defense-in-depth patch validated)
One-line verdict
The size_t subtraction at sys/kern/imgact_shell.c:126 does underflow when
argv[0] is longer than interpreter_tokens + fname, but the wrap is
mathematically harmless: C pointer arithmetic is modular over 2βΆβ΄, so
begin_envv/endp land at the correct intended offset, and space
(truncated back to int) also ends up correct. There is no wild pointer, no
memory corruption, and no panic. Reproduced-as-panic: no.
Classification: false positive (reviewer error in modular-arithmetic
reasoning). A defense-in-depth hardening fix.diff was authored and validated
on a single-fix kernel (it is semantically equivalent β no behavior change).
Evidence (decisive)
Two independent triggers were run on the unpatched audit kernel
(6.5-DEVELOPMENT #0, Thu Jul 2 06:02:54 UTC 2026):
(1) trigger β exec #!/bin/sh with argv[0] swept from 256 B to 262 140 B
(β ARG_MAX). At every length the kernel never panicked, never logged a
warning, and the guest stayed up. The script ran /bin/sh and printed
DF0243_SCRIPT_RAN every time:
=== argv[0] length = 256 === DF0243_SCRIPT_RAN DF0243_CHILD_EXIT code=0 DF0243_NO_PANIC: script executed normally -- underflow did not crash
(Same DF0243_NO_PANIC result for 1024, 4096, 32768, 131072, 262140.)
(2) trigger2 (NEW) β the original PoC used envp={NULL}, so the bcopy at
imgact_shell.c:123-124 copied 0 bytes (n = endp - begin_argv - length = 0)
and the env-shifting path was never exercised. trigger2 passes a large
environment (hundreds of multi-KB env vars), forcing the bcopy to actually
shift real bytes left by (length - offset) while the underflow branch is
active. A sentinel env var (DF0243_SENTINEL=CANARY-7B3F) is checked post-exec:
=== underflow branch + large env: argv0=256, 200 env vars x 1024 bytes === DF0243_ENV_OK argv=0 sentinel_intact DF0243_CHILD_EXIT code=0 (0=script ran with env intact) DF0243_NO_PANIC: argv0=256 nenv=200 envlen=1024 -- bcopy+underflow harmless
(Same for argv0=4096/400Γ256, argv0=32768/100Γ512, argv0=5000/40Γ6000.)
The sentinel surviving intact post-bcopy proves the env block was shifted to
the correct location β i.e. the underflow-adjusted pointers were right.
math_proof.c reproduces the exact arithmetic of imgact_shell.c:117-129 and
shows the "buggy" path produces byte-for-byte the same begin_envv, endp, and
space as the intended signed semantics:
field buggy-path intended expected verdict begin_envv 0x1016 0x1016 0x1016 ALL MATCH endp 0x1016 0x1016 0x1016 ALL MATCH space 262122 262122 262122 ALL MATCH => the size_t wrap is mathematically equivalent to the signed op.
Mechanism walkthrough (why the reviewer was wrong)
The finding's claim hinges on this block (sys/kern/imgact_shell.c:117-129):
offset += strlen(imgp->args->fname) + 1; /* :117 */
length = strlen(imgp->args->begin_argv) + 1; /* :118 */
if (offset > imgp->args->space + length) /* :120 */
return (E2BIG);
bcopy(imgp->args->begin_argv + length, imgp->args->begin_argv + offset,
imgp->args->endp - (imgp->args->begin_argv + length)); /* :123-124 */
offset -= length; /* :126 -- UNDERFLOW (size_t) */
imgp->args->begin_envv += offset; /* :127 */
imgp->args->endp += offset; /* :128 */
imgp->args->space -= offset; /* :129 */
For argv[0] = 256 'A's, #!/bin/sh, fname /tmp/df0243_s:
- offset = 8 (interp) + 14 (fname+1) = 22
- length = 257
- offset -= length β 22 β 257 wraps to 0xFFFFFFFFFFFFFF15 (SIZE_MAX β 234).
The reviewer stopped here and concluded the wrapped value corrupts the pointers. But:
(1) The bcopy at :123-124 is safe in BOTH branches
n = endp - (begin_argv + length) β₯ 0 always (argv[0] is length bytes, endp
is past all content). The destination range is
[begin_argv + offset, begin_argv + offset + n).
- Grow case (offset β₯ length): destination end = endp + (offset β length)
could exceed the buffer β that is exactly what the E2BIG guard at :120
prevents (offset > space + length β E2BIG).
- Shrink case (offset < length, the "underflow"): destination end
= endp β (length β offset) < endp, always within the buffer β the content
only moves left. The guard is irrelevant because no overflow is possible.
So the E2BIG check is correctly targeted at the grow case; the shrink case is inherently safe. The finding's note that "the guard cannot prevent the underflow" is true but irrelevant β the underflow does not need preventing.
(2) begin_envv / endp are char * β modular arithmetic saves them
Both are char * (sys/sys/imgact.h:42-44). Adding 0xFFFFFFFFFFFFFF15 to a
pointer is equivalent, mod 2βΆβ΄, to subtracting 235:
begin_envv_new = begin_envv + (SIZE_MAX β 234)
= (begin_argv + 257) β 235
= begin_argv + 22 β correct, well inside the buffer
This is exactly what the code is trying to compute: the script's argument
buffer shrank from 257 bytes (old argv[0]) to 22 bytes
(/bin/sh\0/tmp/df0243_s\0), so begin_envv/endp must move back by
length β offset = 235 bytes. The wrap produces precisely that result.
(3) space is int β truncation also saves it
space is declared int (sys/sys/imgact.h:46; initialized to ARG_MAX at
sys/kern/kern_exec.c:1028). The expression space -= offset evaluates
(size_t)space β offset and truncates back to int. The magnitude of the
correction is |length β offset|, bounded by ARG_MAX + PAGE_SIZE β 266 KB β
comfortably inside int range. Result: space_new = (ARG_MAX β 257) + 235 =
ARG_MAX β 22 = 262122 (exactly correct).
(4) Downstream exec_copyout_strings also works
After imgact returns 0, kern_exec.c:exec_copyout_strings uses
ARG_MAX β imgp->args->space to size the copyout (kern_exec.c:1166, 1220).
With the corrected values: ARG_MAX β space = 262144 β 262122 = 22 β copies
exactly /bin/sh\0/tmp/df0243_s\0. The new process gets
argv = ["/bin/sh", "/tmp/df0243_s"] β the intended, correct behavior.
Defense-in-depth hardening (fix.diff) β validated
Although there is no security bug (no panic, no corruption), the modular
arithmetic trick at :126-129 is fragile: it relies on (size_t)wrap being
added to a char * and int truncation both "happening to" produce the signed
intent. This is hard to read and arguably relies on pointer arithmetic that,
under a strict reading of the C standard, traverses an out-of-bounds
intermediate value. A maintainer would reasonably want it written explicitly.
fix.diff rewrites the 4 underflow-prone lines as an explicit grow/shrink
branch with no modular wrap:
if (offset >= length) {
size_t grow = offset - length;
imgp->args->begin_envv += grow;
imgp->args->endp += grow;
imgp->args->space -= (int)grow;
} else {
size_t shrink = length - offset;
imgp->args->begin_envv -= shrink;
imgp->args->endp -= shrink;
imgp->args->space += (int)shrink;
}
This is semantically identical to the original modular arithmetic (proven by
math_proof.c and by identical runtime behavior on both kernels). It supersedes
the finding markdown's ## Recommended fix proposal (same approach, cleaner
variable names and a justifying comment).
Validation (Phase 8)
| Step | Result |
|---|---|
git apply --check (temp repo) |
APPLIES_CLEANLY |
Apply to in-guest /usr/src |
Hunk #1 succeeded at line 123 |
make -j6 nativekernel KERNCONF=X86_64_GENERIC |
rc=0 (clean build) |
make installkernel |
rc=0 |
| Reboot into single-fix kernel | kern.version = 6.5-DEVELOPMENT #1: Fri Jul 3 11:50:55 UTC 2026 β booted clean, no panic |
Re-run run.sh (math proof + argv0 sweep) on #1 |
identical to baseline: DF0243_NO_PANIC at every length, ALL MATCH |
Re-run trigger2 (large-env bcopy stress) on #1 |
DF0243_ENV_OK sentinel_intact at every config β identical to baseline |
Conclusion: the hardening patch compiles, boots, and is behaviorally
indistinguishable from the original on both the underflow and grow branches. It
is a valid defense-in-depth cleanup, not a security fix (there was no bug to
fix). Since no bad behavior ever manifested, fix_status = not_applicable for
the security claim; the patch is provided as optional hardening.
PoC changes (this run vs. prior)
trigger2.c(NEW): passes a large environment so thebcopyatimgact_shell.c:123-124actually shifts real bytes (the originaltriggerused emptyenvp, so thatbcopycopied 0 bytes and the env-shift path was never exercised). A sentinel env var verifies the shift landed correctly.setup2.sh(NEW): creates/tmp/df0243_s2, the#!/bin/shscript that checks the sentinel.build.sh/run.sh: extended to build/runtrigger2.fix.diff(NEW): defense-in-depth signed-safe rewrite of:126-129, validated on a single-fix kernel.trigger.c,setup.sh,math_proof.c: unchanged from prior run.
Files in this evidence pack
| File | Purpose |
|---|---|
trigger.c |
original trigger: exec #!/bin/sh with long argv[0] (empty envp) |
trigger2.c |
new: same, but with a large environment stressing the bcopy |
setup.sh |
creates /tmp/df0243_s |
setup2.sh |
new: creates /tmp/df0243_s2 (sentinel-checking script) |
math_proof.c |
proves the wrap == signed op for all 3 fields |
build.sh/run.sh |
exact build/run commands |
build.log |
full compiler output, final successful build |
run.log |
baseline (#0) decisive run: argv0 sweep + large-env stress |
fix_build.log |
full single-fix kernel build (rc=0) |
fix_run.log |
single-fix kernel (#1) run: identical to baseline |
fix.diff |
defense-in-depth signed-safe rewrite (validated, optional) |
env.txt |
guest uname, cc version, kern.argmax |
manifest.json |
machine-readable catalog |
No panic.txt (no panic occurred on either kernel). No dmesg.txt (no kernel
warnings/corruption messages at any point).
Confirmed kernel references
- sys/kern/imgact_shell.c:117
- sys/kern/imgact_shell.c:118
- sys/kern/imgact_shell.c:120
- sys/kern/imgact_shell.c:123
- sys/kern/imgact_shell.c:126
- sys/kern/imgact_shell.c:127
- sys/kern/imgact_shell.c:128
- sys/kern/imgact_shell.c:129
- sys/sys/imgact.h:42
- sys/sys/imgact.h:46
- sys/kern/kern_exec.c:138
- sys/kern/kern_exec.c:1028
- sys/kern/kern_exec.c:1166
- sys/kern/kern_exec.c:1220
Detail
Exploit chain
none (not memory corruption -- the arithmetic is provably safe; no primitive derivable)
Evidence (decisive lines)
=== baseline (#0) argv[0]=256..262140 sweep === DF0243_SCRIPT_RAN DF0243_NO_PANIC: script executed normally -- underflow did not crash === NEW large-env bcopy stress (trigger2, offset<length branch active) === argv0=256 nenv=200 envlen=1024: DF0243_ENV_OK argv=0 sentinel_intact argv0=32768 nenv=100 envlen=512: DF0243_ENV_OK argv=0 sentinel_intact === math_proof.c (kernel arithmetic reproduced in userspace) === begin_envv 0x1016 0x1016 0x1016 ALL MATCH | endp 0x1016 0x1016 0x1016 ALL MATCH | space 262122 262122 262122 ALL MATCH => the size_t wrap is mathematically equivalent to the signed op. Guest status after all runs: up. Panic in boot.log: NONE. dmesg warnings: NONE.
PoC changes
Added trigger2.c (NEW): the original trigger passed envp={NULL} so the bcopy at imgact_shell.c:123-124 copied 0 bytes and never stressed the env-shift path -- trigger2 passes hundreds of multi-KB env vars forcing real byte-shift while the underflow branch is active, with a sentinel env var to verify the shift landed correctly. Added setup2.sh (NEW) for trigger2's script. Extended build.sh/run.sh to build/run trigger2. Added fix.diff (NEW): defense-in-depth signed-safe rewrite of imgact_shell.c:126-129, validated on a single-fix kernel. trigger.c/setup.sh/math_proof.c unchanged.
Verified recommended fix
No security fix needed -- the cited code is not actually vulnerable (pure false positive: modular pointer/int arithmetic yields the correct result). OPTIONAL defense-in-depth hardening in fix.diff: rewrite the 4 modular-arithmetic lines at sys/kern/imgact_shell.c:126-129 as an explicit grow/shrink branch so the intent is obvious and unsigned underflow cannot confuse readers or static analyzers. Supersedes the finding markdown proposal (same approach, cleaner variable names + justifying comment). Validated: git apply --check OK, make -j6 nativekernel rc=0, make installkernel OK, boots to #1 clean, run.sh + trigger2 behave identically to the original on both branches.
Verdict
FALSE POSITIVE. The size_t subtraction at sys/kern/imgact_shell.c:126 (offset -= length) does underflow when strlen(argv[0]) > strlen(interp)+strlen(fname), but the wrap is mathematically equivalent to the intended signed subtraction. (1) The bcopy at :123-124 is safe in the shrink branch: when offset<length content only moves left (dst end = endp-(length-offset) < endp), always within the ARG_MAX+PATH_MAX buffer (allocated at kern_exec.c:138); the E2BIG guard at :120 correctly targets only the grow branch. (2) begin_envv/endp are char* (imgact.h:42-44), so adding (SIZE_MAX-K) is identical mod 2^64 to subtracting K+1 -- the correct intended delta. (3) space is int (imgact.h:46), and (int)((size_t)space - wrapped) truncates back to the correct value since |length-offset| <= ARG_MAX+PAGE_SIZE << INT_MAX. Confirmed empirically: swept argv[0]=256..262140 with NO panic, and a NEW large-environment trigger (trigger2.c) forcing the bcopy to shift real env bytes showed the sentinel env var surviving intact -- the env block landed at the correct shifted location. math_proof.c shows all three fields match byte-for-byte. Downstream exec_copyout_strings (kern_exec.c:1166,1220) sizes the copyout as ARG_MAX-space = 22 bytes = '/bin/sh\0/tmp/df0243_s\0' -- exactly correct. No panic, no corruption, no kernel warnings at any length or env size.
No comments yet.