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.
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
@> riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
@>
...
}
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).
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";
contract WithdrawRewindEscapeHuntTest is BaseConfidencePoolTest {
function testPoC_WithdrawReopensAfterCorruptedWhenRiskWindowUnsealed() external {
uint256 principal = 100 * ONE;
_stake(alice, principal);
assertEq(pool.riskWindowStart(), 0, "risk window not sealed at stake time");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "still unsealed: nobody poked during active risk");
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
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");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.expectRevert();
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();
}