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

In-scope-breached stakers escape with full principal via claimExpired, foreclosing the moderator's CORRUPTED resolution, when the breach materialises near expiry with riskWindowStart == 0

Author Revealed upon completion

Root + Impact

Root cause: claimExpired's auto-resolver refuses to auto-CORRUPT while riskWindowStart == 0 and falls through to EXPIRED (refunding principal) and latches claimsStarted — even when the registry is terminal CORRUPTED (a real in-scope breach).

Impact: an unprivileged staker front-runs the moderator's flagOutcome(CORRUPTED) with claimExpired, permanently escaping an in-scope breach with their full principal and foreclosing the moderator's correct CORRUPTED resolution; recoveryAddress (the protocol's breach compensation) receives nothing.

Description

  • Normal behavior: on a real in-scope breach the registry reaches terminal CORRUPTED; the moderator flags CORRUPTED and the pool sweeps to recoveryAddress (bad-faith) or a named whitehat (good-faith). claimExpired is a permissionless post-expiry backstop.

  • The issue: claimExpired's CORRUPTED branch fires only when state == CORRUPTED && riskWindowStart != 0. When riskWindowStart == 0 (no pool interaction ever observed the active-risk window — a documented reachable state), a CORRUPTED registry is not auto-resolved; execution falls through the else to EXPIRED, refunds the caller's principal, and sets claimsStarted = true, closing the moderator's re-flag window. An in-scope-breached staker thus self-refunds and forecloses the correct CORRUPTED outcome.

// src/ConfidencePool.sol — claimExpired (auto-resolution)
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) { // riskWindowStart==0 SKIPS this
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) revert AgreementCorruptedAwaitingModerator();
outcome = PoolStates.Outcome.CORRUPTED; ...
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) { outcome = SURVIVED; ... }
@> else { outcome = PoolStates.Outcome.EXPIRED; ... } // a CORRUPTED registry with riskWindowStart==0 lands HERE
...
@> claimsStarted = true; // staker latches finality -> moderator can no longer flag CORRUPTED

Why this is not covered by the DESIGN §5 "known issue". DESIGN §5 documents this path but defends it as accepted "because the moderator is expected to flag a known in-scope breach promptly — they have the whole pool term plus the post-expiry window first." That defense is incomplete: the moderator can only flag CORRUPTED once the registry is terminal CORRUPTED. When the breach materialises at/after expiry (the registry marks CORRUPTED near the deadline) with riskWindowStart == 0, the moderator's window to flag before an unprivileged claimExpired front-run is ~zero — a staker calls claimExpired the instant block.timestamp >= expiry. The "flag promptly" assumption fails precisely when the breach and expiry coincide, which is exactly when the escape is valuable to the staker. This is the same finality-foreclosure class as the permissionless-claimCorrupted finding, via a different function and state.

Risk

Likelihood:

  • The registry reaches terminal CORRUPTED (a real in-scope breach) while no pool interaction ever sealed riskWindowStart — a documented reachable state (§5).

  • The breach is confirmed by the registry at or shortly before expiry, so the moderator has not yet flagged CORRUPTED; a staker monitoring the chain calls claimExpired the moment block.timestamp >= expiry, front-running the moderator's flagOutcome.

Impact:

  • The in-scope-breached staker escapes with their full principal instead of losing it to recoveryAddress under the correct CORRUPTED outcome.

  • The moderator's documented CORRUPTED authority is permanently foreclosed (claimsStarted latched); the protocol's breach compensation (recoveryAddress) receives nothing.

Proof of Concept

Full test in test/unit/ConfidencePool.expiredEscapesCorrupted.t.sol (2 tests pass, incl. a control showing the escape is impossible when risk WAS observed). Core:

function test_stakerEscapesInScopeBreachViaExpired() public {
_stake(alice, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // in-scope breach
assertEq(pool.riskWindowStart(), 0); // never observed
vm.warp(block.timestamp + 32 days); // breach + expiry coincide
vm.prank(alice);
pool.claimExpired(); // front-runs moderator
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice), 100 ether); // escaped with full principal
assertEq(pool.claimsStarted(), true);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0)); // foreclosed
assertEq(token.balanceOf(recovery), 0); // recovery gets nothing
}

Recommended Mitigation

When the registry reads CORRUPTED, claimExpired should defer to the moderator regardless of riskWindowStart — i.e., a terminal-CORRUPTED registry must never auto-resolve EXPIRED. Extend the grace-deferral to the riskWindowStart == 0 case:

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
- outcome = PoolStates.Outcome.CORRUPTED;
+ // After grace, still require the moderator's scope judgement rather than auto-EXPIRED;
+ // if the DAO is permanently absent, the scope-blind auto-CORRUPTED backstop applies.
+ outcome = PoolStates.Outcome.CORRUPTED;
...
}

Trade-off (acknowledged): this reintroduces the §6 scope-blind concern for out-of-scope breaches — an out-of-scope breach with riskWindowStart == 0 would now defer/auto-CORRUPT rather than refund. The team may prefer instead to require the moderator to resolve any CORRUPTED registry explicitly and only fall back to EXPIRED once the grace elapses with the registry NOT CORRUPTED.

Support

FAQs

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

Give us feedback!