β¬’ DragonFlyBSD Kernel Audit
← dashboard
DF-0243

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

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
FileTypeDescriptionSize
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
README.md readme human-facing summary + reproduce instructions
↓ download 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 / endp are char * β€” adding SIZE_MAX βˆ’ K to a pointer is identical (mod 2⁢⁴) to subtracting K+1, which is the correct intended delta. The pointer lands safely inside the ARG_MAX + PATH_MAX args buffer.
  • space is int β€” (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
VERDICT.md verdict full narrative: why the size_t underflow is mathematically harmless
↓ download raw

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.

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

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.