Skip to content

Missing overflow check in StableMemory.grow: an attacker-supplied Nat of the form 2^64 - size + K passes the max-pages guard and silently shrinks the logical stable memory (canister brick) #6307

Description

@haoxucu

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

  1. Expose a public method calling StableMemory.grow(n) with the caller-supplied Nat n.
  2. 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).
  3. The i64 sum wraps to K and passes the max-pages guard; set_mem_size(K) is executed.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions