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

Stakers Can Force a Genuinely CORRUPTED Pool to Resolve as EXPIRED by Withholding Interaction During the Attack Window

Author Revealed upon completion

Root + Impact

Description

  • Description
    In claimExpired(), the auto-resolution branch that routes a CORRUPTED registry state to the correct fund-sweep path is gated by a compound condition.

  • riskWindowStart is set lazily — only the first time a gated entrypoint (stake, withdraw, contributeBonus, or pokeRiskWindow) happens to observe the registry while it is in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED). Nothing forces this observation to occur. If the registry transitions NEW_DEPLOYMENT → UNDER_ATTACK → CORRUPTED without anyone calling a gated entrypoint in between, riskWindowStart remains 0 even though the pool is genuinely, terminally corrupted.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Requires only that no gated entrypoint is called during the registry's time in an active-risk state before it reaches CORRUPTED.

  • This is plausible by default for low-traffic pools and becomes actively incentivized the moment any staker recognizes the EXPIRED-vs-CORRUPTED payout asymmetry. No sophistication is required — only inaction.

Impact:Likelihood: Requires only that no gated entrypoint is called during the registry's time in an active-risk state before it reaches CORRUPTED. This is plausible by default for low-traffic pools and becomes actively incentivized the moment any staker recognizes the EXPIRED-vs-CORRUPTED payout asymmetry. No sophistication is required — only inaction.

Proof

Capital intended for recoveryAddress on a confirmed exploit instead pays out to stakers, undermining the core economic guarantee of the pool — that corrupted agreements sweep funds away from the parties who failed to protect them.

function test_Bug_PassiveStakerForcesCorruptedToResolveAsExpired() public {
ClaimExpiredHarnessBuggy pool =
new ClaimExpiredHarnessBuggy(registry, expiry, 100 ether, 10 ether);
// Full attack lifecycle happens on the registry. Crucially, nobody calls
// any gated entrypoint on the pool during this window — the "do nothing"
// strategy every rational staker is incentivized to follow.
registry.setState(IAttackRegistry.ContractState.UNDER_ATTACK);
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "risk window was never ticked");
vm.warp(expiry + 1);
pool.claimExpired();
// BUG CONFIRMED
assertEq(uint256(pool.outcome()), uint256(ClaimExpiredHarnessBuggy.Outcome.EXPIRED));
assertEq(pool.corruptedReserve(), 0, "funds never swept to recoveryAddress");
}
/// @notice Control: if any staker happens to poke the contract while the
/// registry is mid-attack, the same buggy contract resolves correctly.
/// This isolates the bug to the "nobody happened to interact" condition.
function test_Control_PokeDuringAttackResolvesCorruptedCorrectlyEvenOnBuggyContract() public {
ClaimExpiredHarnessBuggy pool =
new ClaimExpiredHarnessBuggy(registry, expiry, 100 ether, 10 ether);
registry.setState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // someone happened to interact -> riskWindowStart ticks
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
assertTrue(pool.riskWindowStart() != 0);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(ClaimExpiredHarnessBuggy.Outcome.CORRUPTED));
assertEq(pool.corruptedReserve(), 110 ether, "funds correctly reserved for sweep");
}
/// @notice Fixed contract: same "nobody interacted" scenario as the core PoC,
/// but now correctly resolves as CORRUPTED regardless of riskWindowStart.
function test_Fix_CorruptedAlwaysResolvesAsCorruptedRegardlessOfObservation() public {
ClaimExpiredHarnessFixed pool =
new ClaimExpiredHarnessFixed(registry, expiry, 100 ether, 10 ether);
registry.setState(IAttackRegistry.ContractState.UNDER_ATTACK);
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "still never ticked, same as buggy scenario");
// Grace period still enforced: claim right at expiry should revert.
vm.warp(expiry + 1);
vm.expectRevert(ClaimExpiredHarnessFixed.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// After grace period, resolves correctly.
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(ClaimExpiredHarnessFixed.Outcome.CORRUPTED));
assertEq(pool.corruptedReserve(), 110 ether, "funds correctly reserved for sweep");
assertTrue(pool.riskWindowStart() != 0, "back-filled by the patch");
}
}

Recommended Mitigation

Stop using riskWindowStart == 0 as a gate on whether the CORRUPTED branch executes at all. Route on the terminal registry state alone, and use riskWindowStart == 0 only to decide whether the value needs to be back-filled for downstream bonus-window math:

- remove this code
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// ... auto-CORRUPTED handling, sweeps to recoveryAddress via claimCorrupted ...
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
...
} else {
// Reached for EVERY non-terminal state, including active-risk (UNDER_ATTACK /
// PROMOTION_REQUESTED). Intentional, NOT a missing active-risk deferral: ...
outcome = PoolStates.Outcome.EXPIRED;
...
}
+ add this code
if (state == IAttackRegistry.ContractState.CORRUPTED) {
if (riskWindowStart == 0) {
// Terminal CORRUPTED observed with no prior risk-window tick: back-fill it now so
// downstream bonus math and the finality checks below stay consistent, rather than
// silently treating this as if no risk ever existed.
// forge-lint: disable-next-line(unsafe-typecast)
riskWindowStart = uint32(block.timestamp) > expiry ? expiry : uint32(block.timestamp);
}
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
...
} else {
outcome = PoolStates.Outcome.EXPIRED;
...
}

Support

FAQs

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

Give us feedback!