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

An unsealed risk window latch lets a staker escape `withdraw()` after risk has materialized and dodging the CORRUPTED sweep

Author Revealed upon completion

Root + Impact

Description

withdraw() should remain permanently disabled once the agreement enters an active-risk state. This prevents a staker from observing an attack and withdrawing before the pool resolves the breach. There is a current implementation to latch the possibility of a benign registry rewind reopening the withdrawfunction, however there is still a caveat to the implementation.

The latch is only sealed if some pool interaction observes the registry in an active-risk state (pokeRiskWindow, stake, etc. calling _observePoolState). If the entire active-risk interval passes with no interaction, riskWindowStart stays 0. A subsequent registry rewind to a pre-attack state then re-opens withdraw(), because the gate is satisfied by riskWindowStart == 0 and a pre-attack live state. The staker escapes principal that the CORRUPTED resolution should have swept.

// src/ConfidencePool.sol — withdraw()
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
@> riskWindowStart != 0 // never sealed if nobody observed the active-risk interval
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
@> // After a rewind to NEW_DEPLOYMENT with riskWindowStart == 0, both clauses are false, withdraw proceeds
...
}

Risk

Likelihood:

  • The active-risk interval elapses with no pool interaction, so riskWindowStart is never sealed.

  • A benign registry migration/rewind reports a pre-attack state after risk materialized.

  • A staker monitors registry state off-chain and calls withdraw() during the rewound pre-attack window, before the moderator finalizes CORRUPTED.

Impact:

  • The escaping staker recovers full principal that an in-scope CORRUPTED breach should have swept to recoveryAddress, defeating the insurance guarantee for that staker.

  • The loss lands on the protocol/recovery destination and is borne unequally. Co-stakers who did not time the rewind still lose their principal to the sweep.

Proof of Concept

The exploit test stakes Alice, drives the registry through UNDER_ATTACK to terminal CORRUPTED with no poke , confirms withdraw is correctly blocked while the live state is terminal, then rewinds to NEW_DEPLOYMENT. After which Alice withdraws her full principal and the later CORRUPTED sweep finds an empty pool (recovery gets 0).

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
//This PoC exercises the complementary, unsealed branch.
contract WithdrawRewindEscapeHuntTest is BaseConfidencePoolTest {
function testPoC_WithdrawReopensAfterCorruptedWhenRiskWindowUnsealed() external {
uint256 principal = 100 * ONE;
// Alice stakes while the agreement is in pre-attack staging (NEW_DEPLOYMENT).
_stake(alice, principal);
assertEq(pool.riskWindowStart(), 0, "risk window not sealed at stake time");
// The agreement is attacked and BREACHED in-scope: registry advances through the active-risk
// states and reaches terminal CORRUPTED. Crucially NOBODY interacts with the pool during
// this interval, so the pool never observes active risk: riskWindowStart stays 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "still unsealed: nobody poked during active risk");
// While the registry reads terminal CORRUPTED, withdraw IS correctly blocked (live-state
// check catches it even with riskWindowStart==0):
vm.prank(alice);
vm.expectRevert(); // WithdrawsDisabled
pool.withdraw();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// THE BUG: withdraw re-opens. The only lock left is the live (rewound) state
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.withdraw();
uint256 aliceGot = token.balanceOf(alice) - aliceBefore;
assertEq(aliceGot, principal, "BUG: staker escaped full principal after a CORRUPTED breach");
// The migration completes and the agreement re-syncs to its true terminal CORRUPTED state.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// The moderator now (correctly) flags the in-scope breach CORRUPTED. But the pool is empty.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.expectRevert(); // NothingToSweep — pool balance is 0
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 0, "LOSS: recovery got 0 of the 100 it was owed");
}
}

Recommended Mitigation

Seal the risk-window latch on any observation past the pre-attack states and not only on active-risk observation. You can have a state variable for this purpose, updated in observePoolStaate.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
+ // Risk has been reachable; disable withdraw one-way even if no active-risk state was
+ // observed and the registry later rewinds to a pre-attack state.
+ withdrawDisabled = true;
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
- riskWindowStart != 0
+ riskWindowStart != 0 || withdrawDisabled
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

Support

FAQs

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

Give us feedback!