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

Post-expiry pokeRiskWindow manufactures the riskWindowStart != 0 gate, flipping the auto-CORRUPTED backstop from principal-returning EXPIRED to a scope-blind sweep of all staker principal

Author Revealed upon completion

Root + Impact

A single post-expiry pokeRiskWindow seals riskWindowStart = expiry for out-of-term risk, satisfying the permissionless auto-CORRUPTED gate and sweeping every staker's principal to recoveryAddress instead of returning it.

Description

  • When a pool's agreement was benign for the entire term (riskWindowStart == 0) and only becomes CORRUPTED after expiry, the permissionless claimExpired backstop must fall through to EXPIRED and return principal. The auto-CORRUPTED branch is gated on riskWindowStart != 0 precisely so unobserved / out-of-term risk cannot trigger a scope-blind principal sweep (DESIGN §6).

  • pokeRiskWindow has no block.timestamp < expiry guard, so a griefer can seal riskWindowStart = expiry from a transient post-expiry active-risk moment. That satisfies the auto-CORRUPTED gate; after expiry + MODERATOR_CORRUPTED_GRACE anyone finalizes the pool as bad-faith CORRUPTED, sweeping all principal to recoveryAddress. Absent the poke, riskWindowStart stays 0 and principal is returned. This falsifies DESIGN §6's claim that "a poke does not manufacture it."

// src/ConfidencePool.sol — claimExpired auto-resolution
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// scope-blind: sweeps the WHOLE pool (principal + bonus) to recoveryAddress
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
}
// riskWindowStart is set to `expiry` by a post-expiry pokeRiskWindow (see _markRiskWindowStart)

Risk

Likelihood:

  • Triggers when a pool's agreement stays benign through the term (riskWindowStart == 0), the moderator does not flag within the 180-day post-expiry grace (the documented moderator-absence backstop this path exists for), and the agreement reaches CORRUPTED after expiry.

  • Realized by a single cheap pokeRiskWindow transaction sent during any transient post-expiry active-risk moment, before the first claimExpired.

Impact:

  • All staker principal is swept to recoveryAddress instead of returned — a full refund is converted into a total loss for every staker.

  • The griefer overrides the conservative, staker-favorable EXPIRED default the design deliberately chose for the no-observed-risk backstop.

Proof of Concept

Identical facts (benign entire term, CORRUPTED after expiry). The exploit adds one post-expiry pokeRiskWindow that seals riskWindowStart = expiry, satisfying the auto-CORRUPTED gate so claimExpired sweeps all principal to recoveryAddress. The control omits the poke: riskWindowStart stays 0, claimExpired resolves EXPIRED, principal is returned. One griefer tx is the difference between total loss and full refund.

// test/poc/PostExpiryPokeAutoCorrupted.t.sol
function testPostExpiryPokeEnablesAutoCorruptedPrincipalSweep() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
vm.warp(pool.expiry());
assertEq(pool.riskWindowStart(), 0); // benign entire term
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(dave); pool.pokeRiskWindow(); // out-of-term seal (missing guard)
assertEq(pool.riskWindowStart(), pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave); pool.claimExpired(); // auto-CORRUPTED (gate satisfied by poke)
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 200 * ONE); // ALL principal swept away
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
// Control: identical facts WITHOUT the poke -> EXPIRED, principal returned.
function testWithoutPokeCorruptedFallsThroughToExpiredAndReturnsPrincipal() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
vm.warp(pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(alice); pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice), 100 * ONE); // principal returned
}

Recommended Mitigation

Same root cause as the bonus-redirect finding — stop opening the risk window after the term ends:

- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowStart == 0 && _isActiveRiskState(state) && block.timestamp < expiry) {
_markRiskWindowStart();
}

With this guard, a post-expiry agreement that was benign in-term keeps riskWindowStart == 0, so claimExpired correctly resolves EXPIRED and returns principal.

Support

FAQs

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

Give us feedback!