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

The CORRUPTED grace period can expire before the moderator is able to act, enabling immediate scope-blind settlement

Author Revealed upon completion

Root Cause And Impact

claimExpired() anchors MODERATOR_CORRUPTED_GRACE to the pool's fixed expiry, even though the
pool moderator cannot classify a corruption until the registry actually enters CORRUPTED:

if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}

Consequently, the entire grace period can elapse while both moderator outcomes are invalid. If the
registry first transitions from UNDER_ATTACK to CORRUPTED after expiry + grace, an arbitrary
caller can immediately backrun that legitimate transition with claimExpired(). The call
permanently finalizes bad-faith CORRUPTED and sets claimsStarted = true, giving a continuously
available pool moderator zero seconds to decide:

  • whether the breach was inside this pool's committed scope; and

  • whether an in-scope breach was good-faith and should pay the named whitehat.

For an out-of-scope or good-faith breach, the permissionless call therefore selects the wrong
economic recipient. claimCorrupted() sends every staker's principal and the entire bonus to the
sponsor-selected recoveryAddress; the moderator can no longer correct the outcome.

Vulnerability Details

The auto-CORRUPTED branch is:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
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;
}

The deadline is independent of the first time state == CORRUPTED can be true. This permits the
following state sequence:

  1. Stakers deposit and the pool observes UNDER_ATTACK, setting riskWindowStart != 0.

  2. The agreement remains UNDER_ATTACK past pool.expiry() + MODERATOR_CORRUPTED_GRACE.

  3. During that period, flagOutcome(SURVIVED) and flagOutcome(CORRUPTED) both revert because
    UNDER_ATTACK is not a terminal registry state.

  4. The registry legitimately transitions to CORRUPTED.

  5. Any account calls claimExpired() in the same block or immediately afterward.

  6. Since the expiry-anchored deadline is already in the past, the pool finalizes bad-faith
    CORRUPTED without giving the moderator an actionable grace period.

  7. claimsStarted = true prevents every later moderator correction.

The upstream state machine permits markCorrupted() from UNDER_ATTACK and
PROMOTION_REQUESTED, and UNDER_ATTACK has no automatic terminal deadline. No false registry
state or malicious dependency behavior is required.

The exploitable pool call is also completely permissionless. The upstream attack moderator may be
performing the correct, ordinary action by recording a real corruption; an unrelated account can
backrun that transaction before the pool moderator has any valid opportunity to classify the
pool-specific outcome. The finding therefore does not depend on treating a trusted administrator's
incorrect input as an exploit.

Why This Is Not The Documented Trust Assumption

The numerical expiry + 180 days anchor is documented. The issue is the unhandled state sequence
in which that deadline passes before the moderator's action precondition becomes satisfiable.

docs/DESIGN.md section 6 justifies scope-blind auto-CORRUPTED settlement as a backstop when the
moderator is unavailable for the full grace window. Here the moderator may be continuously online,
but cannot flag either terminal outcome during the elapsed window because the registry remains
UNDER_ATTACK. The first instant at which the moderator can make the required scope/good-faith
decision is also the first instant at which any caller can permanently bypass it.

This report does not dispute that a scope-blind fallback is intentional after genuine moderator
unavailability. It identifies that the implementation starts the fallback timer before the
moderator can act, so expiration of the timer does not prove the absence that is supposed to
justify the fallback.

Impact And Preconditions

  • The pool must remain unresolved until after expiry + MODERATOR_CORRUPTED_GRACE. A caller could
    have finalized EXPIRED earlier, but the protocol imposes no keeper obligation and unresolved
    pools remain valid on-chain.

  • The registry must first enter CORRUPTED after the fixed deadline. The upstream state machine
    explicitly permits this sequence.

  • Exploitation after that transition is permissionless and immediate; the caller does not need to
    control the pool, registry, moderator, or recovery address.

  • In the affected out-of-scope case, stakers lose all principal and bonus even though SURVIVED
    was the correct pool-specific outcome.

  • In the affected good-faith case, the named whitehat loses the entire bounty because the pool is
    irreversibly classified as bad-faith.

  • The first auto-resolution sets claimsStarted = true, so the legitimate moderator cannot repair
    the result even if it submits its transaction immediately afterward.

The long dormant-pool and late-transition preconditions reduce likelihood, but the impact is the
full pool balance and the race is deterministic once those conditions exist.

Proof Of Concept

Place the following test in test/audit/ConfidencePoolPostExpiryCorruption.t.sol:

function test_corruptionFirstReportedAfterGraceCanBeAutoResolvedImmediately() external {
uint256 principal = 100 * ONE;
uint256 bonus = 50 * ONE;
_stake(alice, principal);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
// The moderator was available, but neither classification was valid before corruption.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// A legitimate late terminal transition occurs. An arbitrary account backruns it.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertEq(pool.corruptedReserve(), principal + bonus);
// The pool moderator now has no correction path.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, principal + bonus);
assertEq(token.balanceOf(alice), 0);
}

Run:

forge test --match-path test/audit/ConfidencePoolPostExpiryCorruption.t.sol -vvv

Result:

3 passed, 0 failed

The PoC proves that the moderator cannot act before the late transition, an unprivileged caller can
finalize immediately afterward, the moderator's correction then reverts, and the entire pool
balance is transferred to recoveryAddress.

Recommended Mitigation

Anchor the emergency fallback to the first successful pool interaction that observes
CORRUPTED. The first claimExpired() observation must return rather than revert, because a revert
would roll back the newly recorded timestamp.

One implementation is:

contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
...
+ uint256 public firstCorruptedObservationAt;
+ event CorruptedGraceStarted(uint256 timestamp);
...
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
+ uint256 observedBefore = firstCorruptedObservationAt;
IAttackRegistry.ContractState state = _observePoolState();
+
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && observedBefore == 0
+ ) {
+ // The call must succeed so this timestamp persists.
+ return;
+ }
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ if (
+ block.timestamp
+ < firstCorruptedObservationAt + MODERATOR_CORRUPTED_GRACE
+ ) {
revert AgreementCorruptedAwaitingModerator();
}
...
}
}
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && firstCorruptedObservationAt == 0
+ ) {
+ firstCorruptedObservationAt = block.timestamp;
+ emit CorruptedGraceStarted(block.timestamp);
+ }
...
}
}

If pokeRiskWindow() is the first successful interaction to observe CORRUPTED, the timestamp is
persisted there. If claimExpired() is the first observer, observedBefore == 0 makes it return
successfully so the timestamp is not rolled back. The moderator then receives the full actionable
grace period. If it remains unavailable after that period, the existing permissionless
bad-faith-CORRUPTED fallback still operates.

Support

FAQs

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

Give us feedback!