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_STACKis infinity (default), the loop runs until themapentzone(ZONE_USE_RESERVE|ZONE_SPECIAL) is exhausted and the kernel panics onzalloc/vm_map_entry_createKASSERTat:1042, taking down the whole machine (system-wide DoS, not just self-DoS). IfRLIMIT_STACKis finite but large, the loop still runsRLIMIT_STACK/sgrowsiztimes (e.g.8 GiB/32 KiB β 262 000iterations), injecting hundreds of thousands of kernel entries, inflating kernel resident memory and making every subsequentvm_maplookup in the victim process slow. - Required config or capabilities:
RLIMIT_VMEMlarge enough tommapan 8 GiB (or larger)MAP_STACKregion β the default on a standard DragonFly install is infinity, so this is satisfied out-of-the-box. - Reachability:
mmapa>4 GiBMAP_STACK, then dereference a volatile pointer whose address is>4 GiBbelow 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;
vmstatshowsvm_map_entrycount climbing rapidly;topshows lots of kernel memory. - With
RLIMIT_STACK=infinityand enough patience, kernel panics invm_map_entry_reserve/vm_map_entry_create(gd_vme_base NULL/zallocexhaustion) β capture the panic string inpanic.txt. - For a non-fatal variant, observe via
/proc/$pid/mapthat 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.
Recommended fix
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
sys/param.h:402βroundupmacro (64-bit arithmetic).sys/vm/vm_mmap.c:1463β usersizepassed straight through tovm_map_stackasmax_ssize.sys/vm/vm_fault.c:487-492βRetryFaultloop that re-entersgrowstack.sys/vm/vm_param.h:134-135βmaxssiz/sgrowsiztunables.
Timeline
- 2026-07-05 Discovered during automated audit.
- pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0940 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
- Userland
MAP_STACKis stripped.sys/vm/vm_mmap.c:429-436(inside themmapsyscall path) explicitly removesMAP_STACKand substitutesMAP_ANONfor 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.
- The only live caller of
vm_map_stack()uses a kernel-controlledmax_ssize.sys/kern/kern_exec.c:991invokes it for the main process stack withmax_ssize = (vm_size_t)maxssiz.maxssizis 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
-
avail_ssizeis therefore always β€ 512 MiB β 128 KiB, far belowINT_MAX(2 GiB). For any fault on the main stack, the 64-bitroundup(stack_entry->ba.start - addr, PAGE_SIZE)always fits inint, so the truncation atvm_map.c:4173never fires. The dead user-mmap caller atvm_mmap.c:1463is the only path that ever passed a user-controlledmax_ssizetovm_map_stack, and it is unreachable. -
RLIMIT_STACK cannot rescue it.
kern_plimit.c:324-327clampsRLIMIT_STACKtomaxssiz:
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)
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 MiBon pc64 (sys/platform/pc64/include/vmparam.h:69). SYSCTL_QUAD(_kern, OID_AUTO, maxssiz, CTLFLAG_RD, ...)atsubr_param.c:102βCTLFLAG_RD, not writable.- Only set at boot via
TUNABLE_QUAD_FETCH("kern.maxssiz", ...)atsubr_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
mapentzoneexhaustion viaRetryFaultloop. - Actual impact: none. The triggering precondition
(
MAP_STACKmmap withsize > 2^32) is unreachable from userspace; and even were it reachable (e.g. by an admin tuningkern.maxssizabove 4 GiB in/boot/loader.conf), the analysis shows the typical failure mode is a single wrong-size fragment followed bySIGSEGV, not an unbounded loop β because onceD(=ba.start - addr) drops below2^32after one truncated growth, the next truncated value becomes negative-as-int, which compares (after promotion tou64) greater thanavail_ssizeand bails at:4174. The finding's "RetryFault loops indefinitely" premise is incorrect;vm_map_insertat:1275-1277returnsKERN_INVALID_ADDRESSfor 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.ctouched*p(lowest byte) and*(p + 3 GiB)and relied on the (incorrect) premise that this would loop inRetryFault. The rewrittentrig.cadditionally dumps/proc/curproc/mapto 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 GiBMAP_STACK) are preserved.
Fix verification
not_testablenot_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.
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.
No comments yet.