FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

Permanent pool lock via accumulator overflow in _markRiskWindowStart

Author Revealed upon completion

Summary

When a pool's total eligible stake grows large enough, the first observation of an active-risk registry state overflows an unchecked-magnitude multiplication in _markRiskWindowStart(). Because that function runs inside _observePoolState(), and nearly every state-changing entrypoint routes through it, the revert bricks the pool: withdraw, claimExpired, flagOutcome, pokeRiskWindow, stake, and contributeBonus all revert permanently, locking every deposited token. No privileged role is required to trigger it.

Vulnerability Details

On the first observation of UNDER_ATTACK / PROMOTION_REQUESTED, _markRiskWindowStart() eagerly resets the global k=2 accumulators:

uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t; // <-- checked-math overflow

t is block.timestamp capped at expiry, so on-chain t ~= 1.75e9 and t^2 ~= 3.06e18. The product totalEligibleStake * t * t reverts once:

totalEligibleStake > 2^256 / t^2 ~= 3.8e58 (base units)

Why individual deposits succeed but the pool still bricks — the key detail: stake() maintains the global accumulator incrementally, adding received * entry * entry at each deposit's entry timestamp. _markRiskWindowStart() does not add — it OVERWRITES sumStakeTimeSq with totalEligibleStake * t * t computed at the observation timestamp (capped at expiry), which is >= every deposit's entry timestamp. Because the window-open recompute uses a larger t than the deposits did, the aggregate can exceed 2^256 at window-open even though every individual stake() (and its incremental accumulator update) stayed safely under it. Time simply has to pass between the deposits and the moment the risk window is first observed — the normal lifecycle of a pool that is later attacked.

Once total stake is in this range and the registry reaches an active-risk state, the next interaction that observes it (withdraw, pokeRiskWindow, flagOutcome, claimExpired, or a new stake) calls _markRiskWindowStart() and reverts. There is no code path that resets totalEligibleStake without first calling _observePoolState(), so the pool cannot recover — all principal and sponsor bonus are permanently locked.

(A single staker suffices under the same timestamp condition; the PoC uses two deposits to also show that each individual stake() succeeds.)

Impact

Permanent, irrecoverable lock of all staked principal and sponsor bonus in the affected pool. The protocol's core guarantee — return funds on SURVIVED/EXPIRED, route to recoveryAddress/attacker on CORRUPTED — can never execute. Unprivileged to trigger.

Proof of Concept

Foundry test test_farfelu_overflowBrick_permanentLock:

  1. Two stakes of 1.885e58 base units are each deposited successfully.

  2. The registry transitions to UNDER_ATTACK.

  3. pokeRiskWindow, withdraw, and (after expiry) claimExpired all revert with an arithmetic-overflow panic — the pool is permanently locked.

A control test test_farfelu_realisticSupplyNoBrick confirms a 1e30-unit stake behaves normally.

Run: forge test --match-test test_farfelu_overflowBrick_permanentLock -vv

Note on Scope and Exploitability

Triggering this requires a stake token whose base-unit supply is far above typical ERC-20s (~3.8e58 base units). EIP-20 places no upper bound on decimals or totalSupply, and neither the factory nor the pool validates them, so a high-decimals or very-high-supply token allowlisted by the factory owner makes this vector live; conventional 18-decimal tokens do not reach it. The contract natspec informally asserts the k=2 products stay "within uint256 for realistic ERC20 supplies," but this permanent-DoS vector is NOT enumerated in docs/DESIGN.md's known issues (sections 1-13).

Recommended Mitigation

Bound the accumulators at deposit time using the maximum possible t (= expiry). In stake(), after computing received, require that the post-deposit total cannot overflow the window-open recompute:

// expiry is the largest t that _markRiskWindowStart can ever use
require((totalEligibleStake + received) <= type(uint256).max / (uint256(expiry) * expiry), "PoolTooLarge");

This makes an oversized pool reject the offending deposit gracefully instead of permanently bricking _observePoolState() for existing depositors. Equivalent: cap totalEligibleStake, or compute the risk-window reset with a saturating/guarded path.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!