β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0940

Integer truncation in vm_map_growstack grow_amount enables unbounded kernel-memory-growth local DoS

Field Value
ID DF-0940
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-197 Integer Truncation Error
File sys/vm/vm_map.c
Lines 4110, 4173, 4213
Area vm
Confidence likely
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

vm_map_growstack() computes the amount to grow a MAP_STACK entry as int grow_amount, but assigns it the result of roundup(stack_entry->ba.start - addr, PAGE_SIZE) which is computed as a 64-bit vm_offset_t. When the process has a stack reserve (avail_ssize) larger than 4 GiB β€” reachable because vm_map_stack's max_ssize is the user-supplied MAP_STACK size and the default RLIMIT_STACK/RLIMIT_VMEM are infinity on DragonFly β€” and a fault occurs more than 4 GiB below the current stack bottom, the growth amount is silently truncated. The truncated value passes the bounds checks and the stack grows by only ~32 KiB instead of the required amount; vm_fault's RetryFault loop re-enters growstack and repeats, each iteration allocating a fresh vm_map_entry + vm_object + pmap updates. With infinity resource limits this is unbounded and can exhaust kernel memory / panic the box; with finite-but-large RLIMIT_STACK it still injects millions of wired kernel map entries.

Root cause

vm_map.c:4110 declares int grow_amount; (signed 32-bit). vm_map.c:4173 does:

grow_amount = roundup(stack_entry->ba.start - addr, PAGE_SIZE);

roundup (sys/param.h:402 β€” (((x)+((y)-1))/(y))*(y)) operates in 64-bit because stack_entry->ba.start - addr is vm_offset_t (uint64_t); the 64-bit result is then stored into the 32-bit int, losing the high bits. addr is constrained to [stack_entry->ba.start - avail_ssize, stack_entry->ba.start) by the check at vm_map.c:4166-4170, and avail_ssize can be > 2^32 because vm_map_stack:4082 sets next->aux.avail_ssize = max_ssize - init_ssize where max_ssize is the user-controlled MAP_STACK size (vm_mmap.c:1463 passes the user size straight through; vm_mmap.c:1232-1255 only caps it via RLIMIT_VMEM, which defaults to infinity).

A second truncation occurs at vm_map.c:4213 grow_amount = roundup(grow_amount, sgrowsiz) (sgrowsiz is u_quad_t, vm_param.h:135).

When the truncated value is a small positive number it bypasses the grow_amount > avail_ssize guard at :4174 and the grow_amount > stack_entry->ba.start - end guard at :4189, and vm_map_insert (:4246) inserts a tiny fragment. vm_fault.c:487-492 then loops back to RetryFault with the original fault address, re-entering growstack, repeating indefinitely.

Threat model & preconditions

  • Attacker position: Local unprivileged user.
  • Privileges gained or impact: No privilege gain, no info leak β€” availability only. If RLIMIT_STACK is infinity (default), the loop runs until the mapentzone (ZONE_USE_RESERVE|ZONE_SPECIAL) is exhausted and the kernel panics on zalloc/vm_map_entry_create KASSERT at :1042, taking down the whole machine (system-wide DoS, not just self-DoS). If RLIMIT_STACK is finite but large, the loop still runs RLIMIT_STACK/sgrowsiz times (e.g. 8 GiB/32 KiB β‰ˆ 262 000 iterations), injecting hundreds of thousands of kernel entries, inflating kernel resident memory and making every subsequent vm_map lookup in the victim process slow.
  • Required config or capabilities: RLIMIT_VMEM large enough to mmap an 8 GiB (or larger) MAP_STACK region β€” the default on a standard DragonFly install is infinity, so this is satisfied out-of-the-box.
  • Reachability: mmap a >4 GiB MAP_STACK, then dereference a volatile pointer whose address is >4 GiB below the current stack top but within the reserved growth region.

Proof of concept

PoC source: findings/poc/DF-0940/trig.c

Build & run

cc -O0 -o trig trig.c
./trig

Expected output

  • Kernel becomes sluggish; vmstat shows vm_map_entry count climbing rapidly; top shows lots of kernel memory.
  • With RLIMIT_STACK=infinity and enough patience, kernel panics in vm_map_entry_reserve/vm_map_entry_create (gd_vme_base NULL / zalloc exhaustion) β€” capture the panic string in panic.txt.
  • For a non-fatal variant, observe via /proc/$pid/map that the stack region is split into hundreds of tiny contiguous entries instead of one.

To prove root cause (not just slowdown), set a ddb breakpoint on vm_map_growstack and confirm grow_amount (register holding the int) is a small value while stack_entry->ba.start - addr is multi-GB.

Impact

Local denial of service β€” kernel memory exhaustion / panic. System-wide on default config (infinite RLIMIT_STACK), not just self-DoS.

Make grow_amount a 64-bit type so the 64-bit roundup result is not truncated. All subsequent comparisons (:4174, :4189, :4214, :4217) are already against unsigned 64-bit lvalues (avail_ssize is vm_offset_t, ba.start-end is vm_offset_t, rlim_cur is rlim_t), so widening grow_amount to vm_size_t only fixes the truncation without altering the comparison semantics.

--- a/sys/vm/vm_map.c
+++ b/sys/vm/vm_map.c
@@ -4107,7 +4107,7 @@ int
 vm_map_growstack (vm_map_t map, vm_offset_t addr)
 {
    vm_map_entry_t prev_entry;
    vm_map_entry_t stack_entry;
    vm_map_entry_t next;
    struct vmspace *vm;
    struct lwp *lp;
    struct proc *p;
    vm_offset_t    end;
-   int grow_amount;
+   vm_size_t      grow_amount;
    int rv = KERN_SUCCESS;
    int is_procstack;
    int use_read_lock = 1;

Additionally, a defense-in-depth cap on max_ssize inside vm_map_stack (e.g. clamp to the global maxssiz tunable, which already exists in vm_param.h:134) would prevent user-initiated MAP_STACK reserves from exceeding the system stack limit regardless of RLIMIT_VMEM.

References

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0940 Β· 16 files
FileTypeDescriptionSize
trig.c trigger-source mmap 8GiB MAP_STACK, dump /proc/curproc/map to show it's a normal anon mapping (MAP_STACK stripped), deep read returns 0x00 with no growstack 2.5 KB view raw
build.sh build-script cc -O0 -o trig trig.c 145 B view raw
run.sh run-script ./trig 137 B view raw
build.log build-log final successful build, full output 26 B view raw
run.log run-log decisive run #1 with /proc/curproc/map dump showing normal anon entry 342 B view raw
run.2.log run-log stress run #2 320 B view raw
run.3.log run-log stress run #3 320 B view raw
map_dump.txt evidence /proc/curproc/map showing 8GiB MAP_STACK became one normal anon entry (no VM_SUBSYS_STACK, no avail_ssize) 342 B view raw
env.txt environment uname, kern.maxssiz=512MiB, kern.maxthrssiz, cc version, ulimits 274 B view raw
VERDICT.md verdict full false-positive narrative with path:line evidence 7.5 KB ↓ raw
README.md readme human-readable summary 4.0 KB ↓ raw
fix.diff suggested-fix git-apply-able defense-in-depth fix: int grow_amount -> vm_size_t grow_amount at vm_map.c:4110 290 B view raw
fix_build.log build-log full single-fix kernel build log (NK_DONE rc=0, 4299 cc, 0 errors) 5.6 MB ↓ download
fix_run.log run-log trigger on patched #1 kernel β€” exits 0, no effect, no regression 346 B view 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 summary
↓ download raw

DF-0940 β€” vm_map_growstack int grow_amount truncation

Verdict: NOT REPRODUCED (false positive for the claimed threat model)

The int grow_amount truncation described in the finding is real in the C source (a 64-bit roundup() result is stored into a 32-bit int), but it is not reachable from an unprivileged user under the default kernel configuration, so the claimed local-DoS / kernel-memory-exhaustion impact does not manifest.

Why it does not reproduce (path:line evidence)

The finding's threat model assumes a user can create a MAP_STACK mapping with an attacker-controlled max_ssize > 2^32. That assumption is wrong:

  1. Userland MAP_STACK is stripped. sys/vm/vm_mmap.c:429-436 (inside the mmap syscall path) explicitly removes MAP_STACK and substitutes MAP_ANON for every userland caller:

c /* The only remaining true MAP_STACK we allow is the user stack as * created by the exec code. All userland MAP_STACK's are converted * to normal mmap()s right here. */ if (flags & MAP_STACK) { ... flags &= ~MAP_STACK; flags |= MAP_ANON; upos = 0; }

This is verified empirically: mmap(NULL, 8 GiB, ..., MAP_STACK|MAP_ANON, -1, 0) succeeds and returns a region whose /proc/curproc/map shows two normal anon entries (0x2180 / 0x0 subsys, no growable-stack semantics) β€” see map_dump.txt. The vulnerable vm_map_growstack path is never entered for this mapping.

  1. The only live caller of vm_map_stack() uses a kernel-controlled max_ssize. sys/kern/kern_exec.c:991 invokes it for the main process stack with max_ssize = (vm_size_t)maxssiz. maxssiz is a boot-time tunable, default 512 MiB on pc64 (sys/platform/pc64/include/vmparam.h:69 + subr_param.c:225), and is exported read-only (CTLFLAG_RD, subr_param.c:102):

$ sysctl kern.maxssiz kern.maxssiz: 536870912 # 512 MiB

  1. avail_ssize is therefore always ≀ 512 MiB βˆ’ 128 KiB, far below INT_MAX (2 GiB). For any fault on the main stack, the 64-bit roundup(stack_entry->ba.start - addr, PAGE_SIZE) always fits in int, so the truncation at vm_map.c:4173 never fires. The dead user-mmap caller at vm_mmap.c:1463 is the only path that ever passed a user-controlled max_ssize to vm_map_stack, and it is unreachable.

  2. RLIMIT_STACK cannot rescue it. kern_plimit.c:324-327 clamps RLIMIT_STACK to maxssiz:

c if (limp->rlim_cur > maxssiz) limp->rlim_cur = maxssiz; if (limp->rlim_max > maxssiz) limp->rlim_max = maxssiz;

And in any case RLIMIT_STACK only affects the runtime check at vm_map.c:4206-4210, not the avail_ssize set at exec time.

What the original PoC actually does

trig.c mmaps 8 GiB MAP_STACK|MAP_ANON, then dereferences deep pointers. Because MAP_STACK is stripped, the region is a normal anon mapping β€” the deep read just returns 0x00 from a zero-filled anon page, the process exits 0, and the kernel is unaffected. Verified over 3 consecutive runs (see run.log, run.2.log, run.3.log); guest stayed up after each.

A separate deep-recursion test on the main stack (deeprec.c) confirms the real growstack path is exercised on the main stack and behaves correctly β€” SIGSEGV cleanly at the maxssiz boundary, kernel healthy.

Defense-in-depth fix

The int grow_amount declaration at vm_map.c:4110 is genuinely wrong β€” it truncates a 64-bit value. The truncation is currently latent (unreachable from userspace), but it is a real code defect that would resurface if userland MAP_STACK were ever re-enabled or if maxssiz were tuned above 2 GiB. The fix is a one-line type widening: int grow_amount; β†’ vm_size_t grow_amount;. All subsequent comparisons are already against unsigned 64-bit lvalues, so the widening is semantics-preserving. See fix.diff.

Build & run

./build.sh     # cc -O0 -o trig trig.c
./run.sh       # ./trig   (exits 0, no effect β€” demonstrates the FP)
VERDICT.md verdict full false-positive narrative with path:line evidence
↓ download raw

DF-0940 β€” vm_map_growstack int grow_amount truncation β€” VERDICT

Verdict: NOT REPRODUCED (false positive for the claimed threat model)

The C-level truncation defect is real: at sys/vm/vm_map.c:4110 an int grow_amount; receives the 64-bit result of roundup(stack_entry->ba.start - addr, PAGE_SIZE) at :4173, silently dropping the high 32 bits. However, the finding's claimed impact (unprivileged local DoS via mmap of an >4 GiB MAP_STACK) does not manifest, because the only path that ever fed a user-controlled max_ssize into vm_map_stack() β€” and thus the only way to produce aux.avail_ssize > 2^32 β€” is dead code. The vulnerability is unreachable from an unprivileged user on the default GENERIC kernel.

Why the PoC does not trigger it (path:line)

(1) Userland MAP_STACK is stripped before vm_map_stack runs

sys/vm/vm_mmap.c:425-439 (inside the mmap syscall) explicitly removes MAP_STACK and substitutes MAP_ANON for every userland caller, with a comment naming the intent:

/* The only remaining true MAP_STACK we allow is the user stack as
 * created by the exec code.  All userland MAP_STACK's are converted
 * to normal mmap()s right here.  */
if (flags & MAP_STACK) {
    if (uap->fd != -1)
        return (EINVAL);
    if ((uap->prot & (PROT_READ|PROT_WRITE)) !=
        (PROT_READ|PROT_WRITE)) {
        return (EINVAL);
    }
    flags &= ~MAP_STACK;
    flags |= MAP_ANON;
    upos = 0;
}

Therefore the vm_map_stack() call at sys/vm/vm_mmap.c:1463 is never reached with user-controlled max_ssize β€” by the time control reaches kern_mmap at vm_mmap.c:1248+, MAP_STACK has been stripped and the mapping is dispatched as a normal anon vm_map_find at vm_mmap.c:1478.

Empirical confirmation: the PoC's mmap(NULL, 8 GiB, ..., MAP_STACK|MAP_ANON, -1, 0) returns a region whose /proc/curproc/map shows a single normal anon entry β€” 0x0000000800a00000 0x0000000a00a00000 -1 -1 0 rw- 0 0 0x0000 NCOW NNC none - (no VM_SUBSYS_STACK, no aux.avail_ssize, no growable semantics). See run.log / map_dump.txt.

(2) The only live vm_map_stack caller uses kernel-controlled max_ssize

sys/kern/kern_exec.c:991 is the sole live caller, invoked during exec to create the main process stack:

error = vm_map_stack(&vmspace->vm_map, &stack_addr, (vm_size_t)maxssiz, ...);

maxssiz is a boot-time tunable, read-only at runtime:

  • Declared u_quad_t maxssiz; (sys/kern/subr_param.c:95).
  • Default MAXSSIZ = 512 MiB on pc64 (sys/platform/pc64/include/vmparam.h:69).
  • SYSCTL_QUAD(_kern, OID_AUTO, maxssiz, CTLFLAG_RD, ...) at subr_param.c:102 β€” CTLFLAG_RD, not writable.
  • Only set at boot via TUNABLE_QUAD_FETCH("kern.maxssiz", ...) at subr_param.c:226.

Verified on the guest: sysctl kern.maxssiz β‡’ 536870912 (512 MiB).

(3) avail_ssize is therefore always < INT_MAX

For the main stack, aux.avail_ssize = max_ssize - init_ssize = maxssiz - sgrowsiz = 512 MiB βˆ’ 128 KiB = 536821760 (well below INT_MAX = 2^31 βˆ’ 1 β‰ˆ 2 GiB). Any addr inside the growable stack region yields ba.start - addr ≀ avail_ssize < 2^31, so roundup(ba.start - addr, PAGE_SIZE) always fits in int β€” the truncation at :4173 never fires for any realistic fault. The bounds check at :4166-4170 already excludes any addr outside [ba.start - avail_ssize, ba.start), so a wild pointer can't sneak a large distance in either.

(4) RLIMIT_STACK cannot rescue it

sys/kern/kern_plimit.c:324-327 clamps RLIMIT_STACK to maxssiz:

if (limp->rlim_cur > maxssiz) limp->rlim_cur = maxssiz;
if (limp->rlim_max > maxssiz) limp->rlim_max = maxssiz;

And in any case RLIMIT_STACK only governs the runtime check at vm_map.c:4206-4210 (and the cap at :4217-4220); it does not influence aux.avail_ssize, which is fixed at exec time.

What the original PoC actually does

The finding's PoC (trig.c as delivered) mmaps 8 GiB MAP_STACK|MAP_ANON, writes the lowest byte, and reads an address ~5 GiB "below" the top. Because MAP_STACK is stripped, the entire 8 GiB is a normal anon mapping; every access is satisfied by the anon pager. The deep read returns 0x00 (zero-filled page). The process exits 0. vm_map_growstack is never called (no VM_SUBSYS_STACK entry exists). No panic, no memory growth, no DoS.

Verified over 3 consecutive runs (run.log, run.2.log, run.3.log); guest stayed up after each.

A second test (deeprec.c) exercises the real growstack path via deep recursion on the main stack β€” it SIGSEGVs cleanly at the maxssiz boundary, kernel unaffected. This is the expected behavior of a correctly-functioning bounded stack.

Impact assessment

  • Claimed impact: Local DoS β€” unbounded kernel memory growth / panic from mapentzone exhaustion via RetryFault loop.
  • Actual impact: none. The triggering precondition (MAP_STACK mmap with size > 2^32) is unreachable from userspace; and even were it reachable (e.g. by an admin tuning kern.maxssiz above 4 GiB in /boot/loader.conf), the analysis shows the typical failure mode is a single wrong-size fragment followed by SIGSEGV, not an unbounded loop β€” because once D (= ba.start - addr) drops below 2^32 after one truncated growth, the next truncated value becomes negative-as-int, which compares (after promotion to u64) greater than avail_ssize and bails at :4174. The finding's "RetryFault loops indefinitely" premise is incorrect; vm_map_insert at :1275-1277 returns KERN_INVALID_ADDRESS for zero-size inserts, breaking the loop.

This is a reviewer false positive on threat-model / reachability grounds, not a "the code is fine" false positive β€” the type bug is real, just latent.

Defense-in-depth fix

Although the bug is unreachable from userspace today, the int grow_amount declaration at vm_map.c:4110 is genuinely wrong and would become exploitable if userland MAP_STACK were ever re-enabled (the dead code at vm_mmap.c:1463 would resurrect) or if an admin tuned maxssiz above 2 GiB. The one-line type widening in fix.diff (int grow_amount; β†’ vm_size_t grow_amount;) closes the latent defect with no semantic change β€” all subsequent comparisons (:4174, :4189, :4214, :4223) are already against unsigned 64-bit lvalues. This matches the finding markdown's ## Recommended fix proposal.

Fix validation

Per Phase 8: fix.diff git apply --check -p1 passes; the single-fix kernel built cleanly (make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ === NK_DONE rc=0 ===, 4299 cc invocations, zero errors); the patched #1 kernel boots and the trigger still exits 0 with no effect (fix_run.log).

fix_status = not_testable β€” because the bug is unreachable, there is no "bad behavior" to reproduce on the baseline that could then be shown absent on the patched kernel. The fix is validated to (a) apply, (b) compile, (c) not regress the guest; the latent code path it closes was confirmed by source tracing above.

PoC changes from the as-delivered version

  • The original trig.c touched *p (lowest byte) and *(p + 3 GiB) and relied on the (incorrect) premise that this would loop in RetryFault. The rewritten trig.c additionally dumps /proc/curproc/map to make the false positive self-evident (the 8 GiB region is one normal anon entry, not a growable stack), and prints explicit "no growstack" markers at each access. The original semantics (deep access on an 8 GiB MAP_STACK) are preserved.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable -- the bug is unreachable from userspace, so no bad-behavior marker. fix.diff validated: applies, compiles (NK_DONE rc=0), boots, trigger exits 0 with no regression.

baseline #0: trig exits 0, normal anon entry in /proc/curproc/map, no growstack. patched #1: identical behavior, no regression. Build: NK_DONE rc=0.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 12:59:56 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 1a9f220610241854bbe3fc31ac0e99f26211e4f18647aca8c7a0830a85477ed3)

Confirmed kernel references

Detail

Exploit chain

none -- non-corruption finding that does not reproduce. The vulnerable code path is unreachable from userspace. No memory corruption primitive, no escalation chain.

Evidence (decisive lines)

run.log: MAP_STACK at 0x800a00000 size 8GiB, /proc/curproc/map shows normal anon entry (no VM_SUBSYS_STACK), trig exits 0, guest unaffected. 3 runs identical. kern.maxssiz=536870912 (512MiB, CTLFLAG_RD).

PoC changes

Rewrote trig.c to dump /proc/curproc/map after MAP_STACK mmap + explicit no-growstack markers. Added build.sh, run.sh, VERDICT.md, fix.diff (defense-in-depth: int -> vm_size_t), manifest.json.

Verified recommended fix

Defense-in-depth (NOT a fix for a reachable bug): change 'int grow_amount;' to 'vm_size_t grow_amount;' at sys/vm/vm_map.c:4110. MATCHES finding markdown's proposal. Full git-apply-able diff in findings/poc/DF-0940/fix.diff.

Verdict

FALSE POSITIVE for the claimed threat model. The int truncation at sys/vm/vm_map.c:4110 is real in C source, but unreachable from unprivileged user: MAP_STACK is stripped at vm_mmap.c:429-436, the only live vm_map_stack caller (kern_exec.c:991) passes kernel-controlled maxssiz (512MiB default, read-only sysctl), so truncation never fires. Empirically: 8GiB MAP_STACK mmap becomes normal anon entry, vm_map_growstack never called, trigger exits 0, guest stays up.