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 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | argv[0]-length sweep trigger (empty envp) | 2.1 KB | view raw |
| trigger2.c | trigger-source | large-environment trigger stressing the bcopy at imgact_shell.c:123-124 | 3.0 KB | view raw |
| trigger3.c | trigger-source | NEW (verify run): max-delta (argv0=ARG_MAX) + nonexistent-interp + 8x75 parallel hammer; 700 underflow execs | 4.3 KB | view raw |
| setup.sh | setup-script | creates /tmp/df0243_s (#!/bin/sh) | 225 B | view raw |
| setup2.sh | setup-script | creates /tmp/df0243_s2 (sentinel-checking #!/bin/sh) | 628 B | view raw |
| math_proof.c | proof-source | userspace proof: size_t wrap == intended signed op for begin_envv/endp/space | 3.0 KB | view raw |
| build.sh | build-script | exact userspace build commands | 104 B | view raw |
| run.sh | run-script | exact run commands (sweep + env stress) | 103 B | view raw |
| build.log | build-log | this run's userspace build output (BUILD_OK, TRIGGER3_BUILT) | 420 B | view raw |
| run.log | run-log | DECISIVE baseline (#0 unpatched): math proof + sweep 256..262140 + env stress + trigger3 all OK, no panic | 4.3 KB | view raw |
| fix_run.log | run-log | single-fix kernel #1 run: byte-identical to run.log (diff empty) | 4.3 KB | view raw |
| fix_build.log | build-log | this run's kernel build/install evidence (rc=0, #1 Oct 12 06:41:45) | 1.4 KB | view raw |
| run.log.pass1 | run-log | pass-1 baseline run (preserved) | 3.0 KB | β download |
| fix_run.log.pass1 | run-log | pass-1 fix-kernel run (preserved) | 1.8 KB | β download |
| fix_build.log.pass1 | build-log | pass-1 full 35k-line kernel build transcript (preserved) | 5.6 MB | β download |
| env.txt | env | both kernel unames, cc 8.3, kern.argmax=262144, end state (reset with-src) | 1013 B | view raw |
| fix.diff | fix-diff | defense-in-depth grow/shrink rewrite of imgact_shell.c:126-129 (validated: LOGS_IDENTICAL) | 1.2 KB | view raw |
| VERDICT.md | verdict-narrative | full narrative: mechanism walkthrough, first-party evidence, fix validation | 3.6 KB | β raw |
| verdict.json | verdict-machine | machine verdict for persist_poc.py / persist_fix.py | 4.8 KB | view raw |
DF-0243 VERDICT -- not_reproduced (impact: none; confidence: certain)
Bottom line
FALSE POSITIVE, independently confirmed. The size_t subtraction at sys/kern/imgact_shell.c:126 does wrap when argv[0] is longer than interpreter tokens + fname, but the wrap is harmless: begin_envv/endp are char so adding (2^64 - d) computes exactly begin_envv-d / endp-d (the intended post-shrink locations, always in-buffer since begin_envv >= begin_argv+length), and space (int) truncates (size_t)space+offset back to the exact intended space+d (bounded by 2ARG_MAX, far inside int range). The finding stopped at 'SIZE_MAX-241' and never evaluated the subsequent pointer adds. On the unpatched kernel (#0 Jul 2 snapshot), 700+ underflow-branch execs -- argv[0] swept 256..262140, argv[0]=262144 at the exact copyinstr bound with offset=15 (max delta d~262129), nonexistent-interpreter #!/a variants, large-env bcopy stress with sentinel verification, and 8x75 parallel execs -- all ran the scripts normally: no panic, no signal, no dmesg warnings, guest stayed up. The claimed kernel panic does not exist; impact none.
Reproduce
build: cd findings/poc/DF-0243 && ./build.sh && cc -O2 -Wall -o trigger3 trigger3.c run: ./run.sh && ./trigger3
Evidence in this pack
run.log (decisive baseline, unpatched #0): math_proof 3x ALL MATCH; 6x DF0243_NO_PANIC sweep; 4x DF0243_ENV_OK sentinel_intact; trigger3 phases (a)-(d) all OK ending 'TRIGGER3_OK: 700 underflow execs, max delta, no crash no signal'; zero CHILD_KILLED. fix_run.log (single-fix kernel #1, this run's build): byte-identical to run.log (diff empty). fix.diff applied to guest /usr/src, nativekernel rc=0, booted #1 Mon Oct 12 06:41:45 UTC 2026. Guest reset with-src afterwards (pristine #0 verified).
Kernel references
sys/kern/imgact_shell.c:52sys/kern/imgact_shell.c:117sys/kern/imgact_shell.c:118sys/kern/imgact_shell.c:120sys/kern/imgact_shell.c:123sys/kern/imgact_shell.c:126sys/kern/imgact_shell.c:127sys/kern/imgact_shell.c:128sys/kern/imgact_shell.c:129sys/sys/imgact.h:42sys/sys/imgact.h:46sys/kern/kern_exec.c:1022sys/kern/kern_exec.c:1028sys/kern/kern_exec.c:1061sys/kern/kern_exec.c:1166sys/kern/kern_exec.c:1220
Fix validation (A/B)
Optional hardening only: rewrite imgact_shell.c:126-129 as an explicit grow/shrink branch (see fix.diff); no security fix required.
Baseline vs post-fix behavior is in run.log / run_fixed*.log (userspace) or panic.txt/hang.txt vs run_fixed_kernel.log (kernel); the identical rig ran both sides.
poc_changes
Added trigger3.c (new): max-delta execs with argv[0]=262144 (the exact ARG_MAX/copyinstr bound) + interp offset=15; nonexistent-interpreter #!/a variant exercising imgact-internal writes alone; large-env + near-max argv[0]; 8x75 parallel hammer. First draft of phase (c) mis-sized argv[0] (262144 + 80 env vars cannot fit ARG_MAX; exec fails EFAULT in exec_copyin_args before imgact) -- fixed by sizing argv0=215024. Re-ran pass-1 trigger/trigger2/math_proof verbatim. No changes needed to pass-1 sources or fix.diff.
Guest: DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 Attempts: 3 Runtime: ~1450s
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testableNo security fix exists to validate: the claimed bug never manifests (baseline_reproduced=0 -- nothing to reproduce; the finding is a false positive). The defense-in-depth fix.diff was nevertheless fully validated this run: applies cleanly, nativekernel build rc=0 (completed Oct 12 06:44:49 UTC), installkernel rc=0, boots as #1, and the entire suite output is byte-identical to the unpatched kernel (diff run.log fix_run.log empty) -- zero behavioral change by design. fix_status recorded as not_testable because persist_fix.py's schema has no not_applicable value.
fix_build.log (BUILD_RC=0, INSTALL_RC=0, kern.version #1 Mon Oct 12 06:41:45 UTC 2026); fix_run.log == run.log (LOGS_IDENTICAL); fix_build.log.pass1 preserves the full 35k-line build transcript of the identical patch.
Confirmed kernel references
- sys/kern/imgact_shell.c:52
- 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:1022
- sys/kern/kern_exec.c:1028
- sys/kern/kern_exec.c:1061
- sys/kern/kern_exec.c:1166
- sys/kern/kern_exec.c:1220
Detail
Evidence (decisive lines)
run.log (decisive baseline, unpatched #0): math_proof 3x ALL MATCH; 6x DF0243_NO_PANIC sweep; 4x DF0243_ENV_OK sentinel_intact; trigger3 phases (a)-(d) all OK ending 'TRIGGER3_OK: 700 underflow execs, max delta, no crash no signal'; zero CHILD_KILLED. fix_run.log (single-fix kernel #1, this run's build): byte-identical to run.log (diff empty). fix.diff applied to guest /usr/src, nativekernel rc=0, booted #1 Mon Oct 12 06:41:45 UTC 2026. Guest reset with-src afterwards (pristine #0 verified).
PoC changes
Added trigger3.c (new): max-delta execs with argv[0]=262144 (the exact ARG_MAX/copyinstr bound) + interp offset=15; nonexistent-interpreter #!/a variant exercising imgact-internal writes alone; large-env + near-max argv[0]; 8x75 parallel hammer. First draft of phase (c) mis-sized argv[0] (262144 + 80 env vars cannot fit ARG_MAX; exec fails EFAULT in exec_copyin_args before imgact) -- fixed by sizing argv0=215024. Re-ran pass-1 trigger/trigger2/math_proof verbatim. No changes needed to pass-1 sources or fix.diff.
Verified recommended fix
Optional hardening only: rewrite imgact_shell.c:126-129 as an explicit grow/shrink branch (see fix.diff); no security fix required.
Verdict
FALSE POSITIVE, independently confirmed. The size_t subtraction at sys/kern/imgact_shell.c:126 does wrap when argv[0] is longer than interpreter tokens + fname, but the wrap is harmless: begin_envv/endp are char so adding (2^64 - d) computes exactly begin_envv-d / endp-d (the intended post-shrink locations, always in-buffer since begin_envv >= begin_argv+length), and space (int) truncates (size_t)space+offset back to the exact intended space+d (bounded by 2ARG_MAX, far inside int range). The finding stopped at 'SIZE_MAX-241' and never evaluated the subsequent pointer adds. On the unpatched kernel (#0 Jul 2 snapshot), 700+ underflow-branch execs -- argv[0] swept 256..262140, argv[0]=262144 at the exact copyinstr bound with offset=15 (max delta d~262129), nonexistent-interpreter #!/a variants, large-env bcopy stress with sentinel verification, and 8x75 parallel execs -- all ran the scripts normally: no panic, no signal, no dmesg warnings, guest stayed up. The claimed kernel panic does not exist; impact none.
No comments yet.