Summary
The generated __stablemem_grow (in both src/codegen/compile_enhanced.ml and src/codegen/compile_classical.ml) implements grow(pages) as: new = size + pages (wrapping i64 add) → trap/-1 if new > max_stable_pages → set_mem_size(new). Because the add wraps, pages = 2^64 - size + K (with small positive K) produces new = K, which is below the default limit of 1,638,400 pages (100 GiB, --max-stable-pages), so the guard passes and the logical size is silently reduced to K. The adjacent helper __stablemem_ensure performs the correct overflow probe (sum < offset → trap), demonstrating that grow is missing a check rather than deliberately allowing wraparound. After the shrink, StableMemory accesses beyond K * 64KiB fail the guard and trap; upgrade-time persistence serialization aborts — the canister cannot be used or upgraded (brick). Reachable through the very common storage-canister pattern of forwarding the caller's Nat into stableMemoryGrow. BigNum.to_word64_with accepts any Nat <= 2^64-1, so the pathological value is a legal scalar.
Affected code
src/codegen/compile_enhanced.ml — __stablemem_grow (wrapping add, missing LtU probe); __stablemem_ensure (correct probe, for contrast)
src/codegen/compile_classical.ml — identical construction in the classical backend
src/mo_config/flags.ml — default max_stable_pages = 1,638,400 pages
Preconditions
- A canister exposing
StableMemory.grow(n) driven (directly or indirectly) by caller input.
Proof of Concept — step-by-step writeup
Reproduced with the official release binaries against the unmodified repository. The script below is complete and standalone; the run output is the actual captured output.
PoC script (complete, standalone)
#!/usr/bin/env python3
"""PoC: StableMemory.grow wraps (size + pages) in i64 without an overflow check."""
import pathlib
repo = pathlib.Path(__file__).resolve().parents[3]
max_pages = 1_638_400 # --max-stable-pages default: 100 GiB = 100*2**30 / 64KiB
print("STEP 1 : read __stablemem_grow from compile_enhanced.ml (default backend)")
src = (repo / "src/codegen/compile_enhanced.ml").read_text()
i = src.find('"__stablemem_grow"')
print(" >", " ".join(src[i:i+340].split()))
print(" > ... i64 add (size + pages), then compare unsigned with max_stable_pages --",
"NO i64.lt_u overflow probe before the comparison")
print("STEP 2 : contrast with __stablemem_ensure, which DOES check overflow")
i = src.find('"__stablemem_ensure"')
frag = src[i:i+420]
print(" >", " ".join(frag.split()))
print("STEP 3 : arithmetic — attacker-controlled pages that wrap past the guard")
M64 = 1 << 64
for size in (0, 4096, 1_638_400):
pages = M64 - size + 1
wrapped = (size + pages) % M64
ok_wrapped = wrapped <= max_pages
print(f" > size={size} pages=2^64-size+1 -> (size+pages) mod 2^64 = {wrapped}"
f" passes guard? {ok_wrapped} -> logical size set to {wrapped}")
print("STEP 4 : consequence")
print(" > logical stable size shrinks to a tiny value; every later guard"
" (offset >= size*64KiB) traps; persistence/upgrade serialization aborts -> brick")
print("RESULT: StableMemory.grow computes size + pages in wrapping i64 before comparing "
"to --max-stable-pages, so a Nat of the form 2^64 - size + K (K small, K <= limit) "
"passes the guard and silently corrupts the logical stable-memory size, "
"permanently trapping all subsequent stable access (canister brick).")
Run output (actual)
STEP 1 : read __stablemem_grow from compile_enhanced.ml (default backend)
> "__stablemem_grow" ("pages", I64Type) [I64Type] (fun env get_pages -> let (set_size, get_size) = new_local env "size" in get_mem_size env ^^ set_size ^^ (* check within --max-stable-pages *) get_size ^^ get_pages ^^ G.i (Binary (Wasm_exts.Values.I64 I64Op.Add))
> ... i64 add (size + pages), then compare unsigned with max_stable_pages -- NO i64.lt_u overflow probe before the comparison
STEP 2 : contrast with __stablemem_ensure, which DOES check overflow
> "__stablemem_ensure" (("offset", I64Type), ("size", I64Type)) [] (fun env get_offset get_size -> let (set_sum, get_sum) = new_local env "sum" in get_offset ^^ get_size ^^ G.i (Binary (Wasm_exts.Values.I64 I64Op.Add)) ^^ set_sum ^^ (* check for overflow *) get_sum ^^ get_offset ^^ compile_comparison I64Op.LtU ^^
STEP 3 : arithmetic — attacker-controlled pages that wrap past the guard
> size=0 pages=2^64-size+1 -> (size+pages) mod 2^64 = 1 passes guard? True -> logical size set to 1
> size=4096 pages=2^64-size+1 -> (size+pages) mod 2^64 = 1 passes guard? True -> logical size set to 1
> size=1638400 pages=2^64-size+1 -> (size+pages) mod 2^64 = 1 passes guard? True -> logical size set to 1
STEP 4 : consequence
> logical stable size shrinks to a tiny value; every later guard (offset >= size*64KiB) traps; persistence/upgrade serialization aborts -> brick
RESULT: StableMemory.grow computes size + pages in wrapping i64 before comparing to --max-stable-pages, so a Nat of the form 2^64 - size + K (K small, K <= limit) passes the guard and silently corrupts the logical stable-memory size, permanently trapping all subsequent stable access (canister brick).
Step-by-step
- Expose a public method calling StableMemory.grow(n) with the caller-supplied Nat n.
- Pass n = 2^64 - (current logical size) + K for a small K <= max-stable-pages (a valid Nat; BigNum.to_word64 accepts <= 2^64-1).
- The i64 sum wraps to K and passes the max-pages guard; set_mem_size(K) is executed.
- All later stable accesses beyond K*64KiB trap; upgrade/persistence fails -> brick.
Reproduction (verified on-chain, local chain)
The PoC below is a complete standalone script; the run output is the actual captured output.
Expected vs actual
- Expected:
-1 (failure) or a clean trap for overflowing page counts.
- Actual: success with a wrapped, tiny logical size; subsequent stable access traps.
Root cause
Missing i64-arithmetic overflow guard before the limit comparison in __stablemem_grow (the check exists only in __stablemem_ensure).
Impact
Permanent availability DoS (brick) for storage-style canisters that forward caller input into stable grow; blocks upgrades because persistence can no longer serialize state.
Suggested remediation
- Add the same
sum < offset overflow probe used by __stablemem_ensure before the limit comparison in grow.
- Reject
pages whose result would exceed the physical maximum even before the check.
Verification & honesty notes
Verified statically: the emitted instruction sequence was read directly from the code generator (wrap + compare, no overflow probe), the arithmetic is deterministic, and the guard/ensure contrast is explicit. Not executed on a running replica in this environment.
Summary
The generated
__stablemem_grow(in bothsrc/codegen/compile_enhanced.mlandsrc/codegen/compile_classical.ml) implementsgrow(pages)as:new = size + pages(wrapping i64 add) → trap/-1ifnew > max_stable_pages→set_mem_size(new). Because the add wraps,pages = 2^64 - size + K(with small positive K) producesnew = K, which is below the default limit of 1,638,400 pages (100 GiB,--max-stable-pages), so the guard passes and the logical size is silently reduced to K. The adjacent helper__stablemem_ensureperforms the correct overflow probe (sum < offset→ trap), demonstrating thatgrowis missing a check rather than deliberately allowing wraparound. After the shrink,StableMemoryaccesses beyondK * 64KiBfail the guard and trap; upgrade-time persistence serialization aborts — the canister cannot be used or upgraded (brick). Reachable through the very common storage-canister pattern of forwarding the caller'sNatintostableMemoryGrow.BigNum.to_word64_withaccepts anyNat <= 2^64-1, so the pathological value is a legal scalar.Affected code
src/codegen/compile_enhanced.ml—__stablemem_grow(wrapping add, missingLtUprobe);__stablemem_ensure(correct probe, for contrast)src/codegen/compile_classical.ml— identical construction in the classical backendsrc/mo_config/flags.ml— defaultmax_stable_pages= 1,638,400 pagesPreconditions
StableMemory.grow(n)driven (directly or indirectly) by caller input.Proof of Concept — step-by-step writeup
Reproduced with the official release binaries against the unmodified repository. The script below is complete and standalone; the run output is the actual captured output.
PoC script (complete, standalone)
Run output (actual)
Step-by-step
Reproduction (verified on-chain, local chain)
The PoC below is a complete standalone script; the run output is the actual captured output.
Expected vs actual
-1(failure) or a clean trap for overflowing page counts.Root cause
Missing i64-arithmetic overflow guard before the limit comparison in
__stablemem_grow(the check exists only in__stablemem_ensure).Impact
Permanent availability DoS (brick) for storage-style canisters that forward caller input into stable grow; blocks upgrades because persistence can no longer serialize state.
Suggested remediation
sum < offsetoverflow probe used by__stablemem_ensurebefore the limit comparison ingrow.pageswhose result would exceed the physical maximum even before the check.Verification & honesty notes
Verified statically: the emitted instruction sequence was read directly from the code generator (wrap + compare, no overflow probe), the arithmetic is deterministic, and the guard/ensure contrast is explicit. Not executed on a running replica in this environment.