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

A pool that survived its full term can be force-settled CORRUPTED via a post-expiry pokeRiskWindow(), sweeping 100% of staker principal to recoveryAddress

Author Revealed upon completion

Root cause + Impact: post-expiry pokeRiskWindow() seals riskWindowStart after the coverage term, force-settling a survived pool CORRUPTED and sweeping 100% of staker principal to recoveryAddress.

Description

  • A pool whose agreement stays non-terminal through expiry survived its coverage term and must resolve EXPIRED, returning principal + bonus (docs/DESIGN.md §2/§5). §5 states the auto-CORRUPTED backstop "cannot apply when no risk window was observed."

  • riskWindowStart is overloaded: for bonus it means "a risk window was observed"; for principal (claimExpired L532) it means "risk materialized in-term" — nothing enforces the second. _markRiskWindowStart caps the seal to expiry yet still writes non-zero, and pokeRiskWindow() is permissionless with no block.timestamp < expiry guard (it blocks only post-resolution). A poke during a post-term active-risk state therefore seals riskWindowStart = expiry (non-zero) with zero in-term risk, arming the CORRUPTED sweep against a survivor.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return; //@> guards post-RESOLUTION, NOT post-EXPIRY
_observePoolState(); //@> seals riskWindowStart even after expiry
}
// _markRiskWindowStart(): if (t > expiry) t = expiry; riskWindowStart = uint32(t); //@> caps value, still NON-ZERO
// claimExpired() L532: if (state == CORRUPTED && riskWindowStart != 0) //@> read as in-term risk -> auto-CORRUPTED sweep

Not the §6 accepted case: §6 exempts an out-of-scope breach (spatial) observed in-term; this is a post-term breach (temporal), which §2 defines as EXPIRED. §6's "a poke does not manufacture it" is disproven by the control (no poke → EXPIRED) vs exploit (one poke → swept). The fix is lossless, confirming an unintended conflation.

Risk

Likelihood:

  • Triggers when a pool survives its term, the agreement is breached after the term, and any address calls the permissionless pokeRiskWindow() before claimExpired; the principal sweep additionally needs the moderator absent for the 180-day grace (the backstop condition).

  • The bonus-misdirection facet needs no moderator absence and no breach — only a post-term active-risk state plus a poke.

Impact:

  • 100% of staker principal (+ bonus) is swept to the sponsor-controlled recoveryAddress for a pool that won its bet; the outcome is irreversibly latched.

  • Unclaimed bonus that §5 routes to recoveryAddress is instead paid to stakers (the globalScore == 0 fallback) — value misdirected.

Proof of Concept

test/unit/PocPostExpiryPokeSweep.t.sol (5/5) and test/fork/PocPostExpiryPokeSweep.fork.t.sol (2/2, live BattleChain testnet chainId 627 — real SafeHarborRegistry/AttackRegistry/Agreement via the real factory path; only getAgreementState's state sequence is vm.mockCall'd). Each exploit is paired with a passing control:

_stake(alice, 100e18); // NEW_DEPLOYMENT the whole term -> riskWindowStart == 0
vm.warp(pool.expiry() + 1); // survived the term
attackRegistry.setAgreementState(UNDER_ATTACK); // post-term breach begins
vm.prank(dave); pool.pokeRiskWindow(); //@> seals riskWindowStart = expiry (the bug)
attackRegistry.setAgreementState(CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired(); // outcome == CORRUPTED (control, no poke -> EXPIRED)
pool.claimCorrupted(); // 100e18 -> recoveryAddress, alice gets 0 (control -> alice gets 100e18)

Recommended Mitigation

Gate the CORRUPTED backstop on a dedicated in-term flag, leaving riskWindowStart (and its bonus clamp) untouched.

// _observePoolState():
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
+ if (block.timestamp <= expiry) riskMaterializedInTerm = true; // NEW one-way, in-term only
}
// claimExpired() L532:
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskMaterializedInTerm) {

Support

FAQs

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

Give us feedback!