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

Anyone can poke the risk window during a brief UNDER_ATTACK transit, permanently arming the auto-CORRUPTED backstop against stakers

Author Revealed upon completion

Root + Impact

pokeRiskWindow() is permissionless. If someone calls it during even a single-block UNDER_ATTACK window, it seals riskWindowStart, which is the gate that decides whether claimExpired() can auto-resolve as CORRUPTED. Without that poke, the fallback would be EXPIRED and stakers keep their principal.

The auto-CORRUPTED path in claimExpired() (line 532) checks:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {

So if nobody ever poked during UNDER_ATTACK, riskWindowStart stays at 0 and the pool falls through to EXPIRED - stakers get their money back. That's the safe default.

But pokeRiskWindow() can be called by anyone, and it triggers _observePoolState() which seals riskWindowStart if the registry is in an active-risk state. There's no minimum duration requirement.

Scenario:

  1. Agreement briefly enters UNDER_ATTACK

  2. Someone pokes immediately - riskWindowStart is now set

  3. Agreement eventually hits CORRUPTED (out-of-scope breach)

  4. Moderator goes dark

  5. After expiry + 180 day grace, anyone calls claimExpired() -> auto-CORRUPTED fires

  6. Entire pool sweeps to recoveryAddress

Without step 2, stakers would have gotten their principal back.

Proof of Concept

function testPokeArmsAutoCorruptedAfterBriefRiskTransit() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 aliceInitial = token.balanceOf(alice);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "riskWindowStart sealed by poke");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
assertEq(token.balanceOf(alice), aliceInitial);
}

Recommended Mitigation

Require pokeRiskWindow to observe the active-risk state for at least some minimum duration (e.g. 1 hour) before sealing. Or document this edge case explicitly.

Support

FAQs

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

Give us feedback!