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.
PoC verification
Evidence pack
findings/poc/DF-0243 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | supplied PoC: exec #!/bin/sh with a long argv[0] (unchanged from filing) | 2.1 KB | view raw |
| setup.sh | setup-script | supplied: creates /tmp/df0243_s as #!/bin/sh\necho DF0243_SCRIPT_RAN | 225 B | view raw |
| math_proof.c | analysis-tool | NEW: standalone proof that the size_t wrap == signed op for all 3 fields | 3.0 KB | view raw |
| build.sh | build-script | exact build: cc -O2 -Wall trigger.c, math_proof.c, then setup.sh | 445 B | view raw |
| run.sh | run-script | runs math_proof, then sweeps argv[0] lengths 256..262140 | 584 B | view raw |
| build.log | build-log | full compiler output of final successful build (clean, no warnings) | 250 B | view raw |
| run.log | run-log | decisive run: math proof output + 6 argv[0] lengths, all no-panic | 1.8 KB | view raw |
| env.txt | environment | uname, cc version, kern.argmax, guest state | 845 B | view raw |
| VERDICT.md | verdict | full narrative: why the size_t underflow is mathematically harmless | 6.4 KB | β raw |
| README.md | readme | human-facing summary + reproduce instructions | 3.2 KB | β raw |
DF-0243 β PoC evidence pack
Finding: size_t underflow in exec_shell_imgact when argv[0] is longer
than interpreter + fname β alleged 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.
Result: no wild pointer, no memory corruption, no panic. The script runs
/bin/sh /tmp/df0243_s normally and prints DF0243_SCRIPT_RAN.
See VERDICT.md for the full line-by-line mechanism walkthrough.
How to reproduce
./build.sh # cc trigger.c, cc math_proof.c, create /tmp/df0243_s
./run.sh # prints the math proof, then sweeps argv[0] lengths
Expected on the audited master DEV 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
No panic at any length. The guest stays up.
Why no fix.diff
This is a pure false positive β the cited code is not actually vulnerable. The
finding's proposed if/else rewrite is semantically identical to the existing
modular arithmetic (it computes the same pointer deltas and same space).
A purely cosmetic readability patch could be entertained, but no security
fix is warranted and the instructions direct that pure false-positives omit
fix.diff.
Files
| File | Purpose |
|---|---|
trigger.c |
supplied PoC (unchanged) |
setup.sh |
supplied: creates /tmp/df0243_s |
math_proof.c |
NEW: arithmetic proof that wrap == signed op for all 3 fields |
build.sh |
exact build commands |
run.sh |
exact run command (math proof + argv[0] length sweep) |
build.log |
full final build (clean) |
run.log |
full decisive run |
env.txt |
guest environment |
VERDICT.md |
full narrative analysis |
manifest.json |
machine-readable catalog |
DF-0243 β VERDICT: NOT REPRODUCED / FALSE POSITIVE
One-line verdict
The size_t subtraction at sys/kern/imgact_shell.c:126 does underflow when
argv[0] is longer than interpreter + fname, but the wrap is mathematically
harmless: pointer arithmetic is modular, 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).
Evidence (decisive)
Running the supplied trigger (trigger.c, exec'ing #!/bin/sh with a long
argv[0]) at every length from 256 B up to 262 140 B (~ARG_MAX) β 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:
$ ./trigger 256 DF0243_SCRIPT_RAN DF0243_CHILD_EXIT code=0 DF0243_NO_PANIC: script executed normally -- underflow did not crash
(Same result for 1024, 4096, 32768, 131072, 262140.)
math_proof.c in this folder 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) begin_envv / endp are pointers β 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)
= (buf + 257) β 235
= buf + 22 β correct, well inside the ARG_MAX 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.
(2) space is int β truncation also saves it
space is declared int (sys/sys/imgact.h:46). 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 = 261887 + 235 = 262122 (== ARG_MAX β 22, exactly correct)
(3) The E2BIG guard at :120 is irrelevant to the crash claim
The finding notes the guard cannot prevent the underflow. That's true β but the underflow itself is harmless per (1) and (2), so the guard being a no-op for this case is fine.
Why exec_copyout_strings also doesn't blow up
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)
and walks argc NUL-terminated strings from begin_argv
(kern_exec.c:1231-1236). With the corrected pointers above:
- ARG_MAX β space = 262144 β 262122 = 22 β copies exactly
/bin/sh\0/tmp/df0243_s\0 to the user stack.
- argc = 2 β walks two strings, matching the 22 bytes.
The new process gets argv = ["/bin/sh", "/tmp/df0243_s"] β the intended,
correct behavior. The leftover AAAA... bytes that were in buf[22..256] are
never read because endp correctly bounds the live region at buf+22.
Why the "Recommended fix" in the finding is unnecessary
The proposed if/else rewrite:
if (offset >= length) {
size_t net = offset - length;
imgp->args->begin_envv += net; ...
} else {
size_t net = length - offset;
imgp->args->begin_envv -= net; ...
}
β¦is semantically identical to the existing code. It computes the same
pointer deltas and the same space value. It is arguably more readable
(defense-in-depth / clarity), but it does not fix a bug because there is
no bug: the original modular arithmetic already produces the correct result in
both branches. A purely cosmetic hardening patch could be entertained for
readability, but no security fix is warranted.
Files in this evidence pack
| File | Purpose |
|---|---|
trigger.c |
supplied PoC (unchanged): exec #!/bin/sh with long argv[0] |
setup.sh |
supplied: creates /tmp/df0243_s |
math_proof.c |
new: proves the wrap == signed op for all 3 fields |
build.sh |
exact build commands |
run.sh |
exact run command (sweeps several argv[0] lengths) |
build.log |
full compiler output, final successful build |
run.log |
full decisive run (256 B) + length sweep |
env.txt |
guest uname, cc version, kern.argmax |
manifest.json |
machine-readable catalog |
No panic.txt (no panic occurred). No fix.diff (pure false positive β no code
change warranted).
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: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:43
- sys/sys/imgact.h:44
- sys/sys/imgact.h:46
- sys/kern/kern_exec.c:1166
- sys/kern/kern_exec.c:1220
- sys/sys/syslimits.h:47
Detail
Evidence (decisive lines)
findings/poc/DF-0243/run.log shows DF0243_SCRIPT_RAN + DF0243_NO_PANIC at all 6 argv[0] lengths with RUN_SH_EXIT=0; guest status 'up' after the sweep. findings/poc/DF-0243/math_proof.c output (in run.log) shows begin_envv/endp/space all ALL MATCH between buggy-path (0x1016/0x1016/262122), intended (0x1016/0x1016/262122), and expected (buf+22 / ARG_MAX-22=262122). build.log shows clean build. env.txt captures uname 6.5-DEVELOPMENT and kern.argmax=262144. No panic.txt (no panic occurred).
PoC changes
Added math_proof.c (standalone proof that the size_t wrap == signed op for all 3 fields) and the build.sh/run.sh/env.txt/VERDICT.md/README.md/manifest.json evidence-pack files. The supplied trigger.c and setup.sh were used unchanged (they already built and ran correctly on the first try).
Verdict
FALSE POSITIVE. The size_t subtraction offset -= length at sys/kern/imgact_shell.c:126 DOES underflow when argv[0] is longer than interpreter+fname, but the wrap is mathematically harmless: begin_envv/endp are char * (sys/sys/imgact.h:42-44), so adding SIZE_MAX-K is identical mod 2^64 to subtracting K+1 -- the pointer lands at the correct intended offset (buf+22 for the finding's example), safely inside the ARG_MAX+PATH_MAX args buffer. space is int (sys/sys/imgact.h:46); (int)((size_t)space - wrapped_offset) truncates back to the correct value because |length-offset| <= ARG_MAX+PAGE_SIZE << INT_MAX. Confirmed at runtime: the trigger exec'd #!/bin/sh with argv[0] lengths 256, 1024, 4096, 32768, 131072, and 262140 (~ARG_MAX) -- every run printed DF0243_SCRIPT_RAN (script ran normally), no panic, no dmesg warning, guest stayed up. math_proof.c in the folder reproduces the exact kernel arithmetic and shows the 'buggy' path, the intended signed path, and the expected post-rearrangement value ALL MATCH for begin_envv, endp, and space.