
Spill Slot Collision Across Mutual Recursion Bug
The Solidity team disclosed a compiler bug (fixed in Solidity 0.8.37) where the IR pipeline's stack-to-memory spill mechanism can assign the same fixed memory offset ("spill slot") to two simultaneously-live local variables when a contract's call graph contains mutual recursion, silently corrupting values without any revert or warning. The bug requires --via-ir compilation plus a narrow set of conditions (mutual recursion, >16 live locals in reachable non-recursive functions, a variable read back after the recursive call) and affects Solidity 0.7.2 through 0.8.36; a rescan of ~319,000 Sourcify-verified contracts found no affected deployments. The writeup includes full technical detail on the buggy depth-first slot allocator, an annotated PoC contract reproducing the silent overwrite, and the strongly-connected-component fix.
What it is
A miscompilation in Solidity's --via-ir pipeline (the stack limit evader's stack-to-memory mover), reported by Ng Sze Hon via the EF bug bounty, fixed in 0.8.37, present since 0.7.2. Two simultaneously-live locals in different non-recursive functions get assigned the same fixed "spill slot" when the call graph contains a mutual-recursion cycle, silently corrupting one value — no warning, no revert. Distinct from the Unsound Spill In Mutual Recursion Bug (fixed 0.8.36): that was a misclassification that spilled a recursive function's variables; this one classifies correctly but violates a different invariant (two live variables sharing a slot), so it survives even in 0.8.36.
How it works
The slot allocator walks the call graph depth-first, caching each function's slot requirement. To avoid infinite descent on cycles, it writes a provisional 0 on entry and replaces it only after all callees are explored. The bug: a function reached back through a cycle reads that provisional 0 as if final, undercounts, and caches the wrong value — which then propagates to unrelated later callers. The region is sized for one slot where two are needed, so two live variables collide. Whether it triggers depends on traversal order (source-Yul call order); the function that first enters the cycle always computes correctly.
Preconditions / Affected
All must coincide (first two are checkable; the rest depend on compiler internals):
- compiled with
--via-ir/settings.viaIR(--experimental-via-irbefore 0.8.13), - internal functions forming a cycle of ≥2 (self-recursion alone does not trigger it),
- a non-recursive function reachable from the cycle with >~16 live values (forcing spilling),
- a second non-recursive function that spills, calls into the cycle, and reads a spilled variable back after the call returns,
- no recursive function needs its own spilling (else the compiler skips relocation),
- traversal order does not mitigate it.
Version nuance: on 0.7.2–0.8.20 the mover runs only with --optimize, so both --via-ir and --optimize are needed; on 0.8.21+ it runs regardless of the optimizer. Only the deployed contract's settings matter, not CI/test settings. Mutual recursion is judged on compiled Yul, so mutually recursive Yul in inline assembly counts. Counterintuitively, inline assembly not annotated memory-safe cannot be affected (relocation only runs on memory-safe code). A rescan of ~319,000 Sourcify contracts found none affected.
What to do
- Upgrade to 0.8.37+ before deploying via-IR contracts.
- For already-deployed via-IR contracts: if there is no mutual recursion (Solidity or inline assembly), you are safe. If mutual recursion is present, no source-level check rules it out — review, focusing on locals read back after internal calls that lead into the recursion.
The fix reworks the walk to operate per strongly-connected component (mutual-recursion cycles collapse into one unit sized together; intra-component calls ignored). Components form an acyclic graph, so no provisional value is ever read. Upgrading changes only region size and slot assignment — it will not make a currently-compiling contract fail with "Stack too deep" (unlike the 0.8.36 fix).
Caveats
Corruption is deterministic: a test suite compiled with the same settings that exercises the path will reproduce the wrong result, though the cause is non-obvious. Severity is rated low/medium; the pattern is very uncommon in practice.
Organisations named here
- Ethereum Foundation bug bounty program
- Ng Sze Hon
Technologies named here
- Solidity compiler
- Sourcify database
- Property-based fuzz test
- Solidity
- Ethereum
- Sourcify
Techniques named here
- Spill slot collision across mutual recursion
- Unsound Spill In Mutual Recursion Bug
- Slot-allocation undercount via provisional cache value
Related in Blockchain

THORChain Exploit Report #2 — GG20 root-cause disclosure

Black Thursday revisited: MakerDAO keeper exploited for $500K
.jpg)