β¬’ DragonFlyBSD Kernel Audit
← triage Β· 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.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0243 Β· 17 files
FileTypeDescriptionSize
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
README.md readme human-readable evidence-pack index
↓ download 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 / 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.
  • The bcopy at :123-124 is safe in the shrink branch (content only moves left, always within the buffer); the E2BIG guard at :120 correctly 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
VERDICT.md verdict full narrative: false-positive mechanism + hardening patch validation
↓ download raw

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 the bcopy at imgact_shell.c:123-124 actually shifts real bytes (the original trigger used empty envp, so that bcopy copied 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/sh script that checks the sentinel.
  • build.sh / run.sh: extended to build/run trigger2.
  • 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

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.