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

Permissionless post-expiry risk-window poke enables later full-pool CORRUPTED sweep

Author Revealed upon completion

Summary

ConfidencePool treats expiry as the end of the underwriting term. Once the pool has expired, stakers should be able to resolve through claimExpired() unless the registry already supports a valid CORRUPTED resolution path. The issue is that pokeRiskWindow() remains callable after expiry while the pool is still unresolved, and it can write riskWindowStart = expiry without resolving the pool.

That post-expiry write changes the meaning of a later claimExpired() call. If the registry becomes CORRUPTED after the pool term, the previously written nonzero riskWindowStart satisfies the auto-CORRUPTED backstop condition. A permissionless caller can then resolve the pool as bad-faith CORRUPTED, and claimCorrupted() sweeps the full affected pool balance to the sponsor-controlled recovery address. This is not theft by the caller, but it is full loss of principal and bonus for stakers in the affected pool.

Affected Contract

File: src/ConfidencePool.sol

Lines: 649-657

Function/Type: pokeRiskWindow()

File: src/ConfidencePool.sol

Lines: 801-817

Function/Type: _markRiskWindowStart()

File: src/ConfidencePool.sol

Lines: 512-607

Function/Type: claimExpired()

File: src/ConfidencePool.sol

Lines: 532-554

Function/Type: Auto-CORRUPTED branch in claimExpired()

File: src/ConfidencePool.sol

Lines: 408-426

Function/Type: claimCorrupted()

Vulnerability Details

pokeRiskWindow() is permissionless and only checks that the pool outcome is still UNRESOLVED. It does not check whether the underwriting term has already ended. When the registry is in UNDER_ATTACK or PROMOTION_REQUESTED, _observePoolState() calls _markRiskWindowStart().

_markRiskWindowStart() caps the observed timestamp to expiry when called after expiry. That cap prevents a timestamp above the formula upper bound, but it also lets a post-expiry observation create a nonzero riskWindowStart as if active risk had been observed during the covered term. The call then returns without finalizing the expired pool.

Later, if the registry is marked CORRUPTED and the moderator grace period has elapsed, claimExpired() checks state == CORRUPTED && riskWindowStart != 0. The stale post-expiry marker satisfies that condition, so the pool resolves as bad-faith CORRUPTED. claimCorrupted() then transfers the full token balance to recoveryAddress.

The repository already contains regression tests showing that late risk-window mutation is dangerous after resolution: testPokeRiskWindowDoesNotMutateRiskWindowPostResolution() asserts that pokeRiskWindow() must be a no-op once the pool has resolved. The missing guard is the equivalent pre-resolution expired state: the pool is already past the covered term but can still be mutated by a non-claiming observer.

Root Cause

The root cause is that pokeRiskWindow() can mutate risk-window state after expiry without atomically resolving the expired pool.

// File: src/ConfidencePool.sol:649-657
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
// File: src/ConfidencePool.sol:801-817
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
// File: src/ConfidencePool.sol:523-554
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
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;
}
// File: src/ConfidencePool.sol:408-426
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}

Attack Vectors

Attack Vector 1: Post-expiry active-risk marker injection

Step 1 Stakers deposit and the pool reaches expiry while still unresolved.
Step 2 The registry enters UNDER_ATTACK or PROMOTION_REQUESTED after the pool term.
Step 3 Any caller invokes pokeRiskWindow().
Result: riskWindowStart is set to expiry, even though the active-risk observation happened after the covered term, and the pool remains unresolved.

Attack Vector 2: Later bad-faith auto-CORRUPTED sweep

Step 1 The post-expiry poke has made riskWindowStart nonzero.
Step 2 The registry later becomes CORRUPTED.
Step 3 After MODERATOR_CORRUPTED_GRACE, any caller invokes claimExpired().
Step 4 Any caller invokes claimCorrupted().
Result: The full affected pool balance, including staker principal and bonus, is swept to recoveryAddress.

Execution Flow

  • The pool passes expiry with outcome == UNRESOLVED.

  • The registry later enters an active-risk state.

  • pokeRiskWindow() is called after expiry.

  • _observePoolState() calls _markRiskWindowStart().

  • _markRiskWindowStart() caps block.timestamp to expiry and stores that value as riskWindowStart.

  • The pool remains unresolved.

  • The registry later becomes CORRUPTED.

  • After the grace period, claimExpired() sees state == CORRUPTED && riskWindowStart != 0.

  • The pool resolves as bad-faith CORRUPTED.

  • claimCorrupted() sweeps the full affected pool balance to recoveryAddress.

Impact

This is a Critical candidate when the judging rubric treats full loss of funds in an affected pool as Critical. A permissionless caller can convert an already-expired unresolved pool from the expected EXPIRED settlement path into a later bad-faith CORRUPTED path. Stakers in that pool lose all principal and bonus once claimCorrupted() executes.

The attacker's direct cost is only gas and no stake ownership is required. The attacker does not receive the funds directly, but the security impact is still severe because the final value movement is a full-pool sweep to the sponsor-controlled recovery address instead of the expiry payout owed to stakers for the completed term.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolPostExpiryPoC.t.sol

function testPoC_postExpiryPokeEnablesLaterAutoCorruptedSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry, "post-expiry poke sealed the risk window");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "pool remains unresolved");
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "post-term corruption wins");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal and bonus swept");
assertEq(token.balanceOf(alice), 0, "expired staker receives nothing");
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
}

How to Run

forge test --match-path test/poc/ConfidencePoolPostExpiryPoC.t.sol -vvv

Captured Test Output

Ran 1 test for test/poc/ConfidencePoolPostExpiryPoC.t.sol:ConfidencePoolPostExpiryPoC
[PASS] testPoC_postExpiryPokeEnablesLaterAutoCorruptedSweep() (gas: 529693)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Log-to-Impact Walkthrough

The passing PoC proves the full exploit chain. First, pokeRiskWindow() sets riskWindowStart to expiry after the pool term and leaves the pool unresolved. Next, the registry is moved to CORRUPTED, and claimExpired() resolves the pool as CORRUPTED because the post-expiry marker is nonzero. Finally, claimCorrupted() transfers 150 * ONE to recovery and Alice receives nothing.

Key Assertions Proven

assertEq(pool.riskWindowStart(), expiry) -> proves the post-expiry poke creates the risk-window marker -> PASS
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED)) -> proves the poke does not resolve the expired pool -> PASS
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED)) -> proves the later expiry path resolves as CORRUPTED -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE) -> proves full affected-pool principal and bonus are swept -> PASS
assertEq(token.balanceOf(alice), 0) -> proves the expired staker receives no payout -> PASS

Recommended Fix

Prevent pokeRiskWindow() from mutating risk-window state after expiry. Post-expiry observation and outcome selection should happen only inside claimExpired(), where the state observation and resolution are atomic.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

A stricter variant is to revert instead of returning, so callers cannot mistake a post-expiry poke for a successful risk-window observation:

+error PoolAlreadyExpired();
+
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) revert PoolAlreadyExpired();
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Support

FAQs

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

Give us feedback!