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

Withdrawals Remain Enabled During ATTACK_REQUESTED, Allowing Users to Exit Before the Pool Is Frozen

Author Revealed upon completion

Root + Impact

Description

The withdraw() function allows withdrawals while the registry state is ATTACK_REQUESTED, provided riskWindowStart is still 0.

Although ATTACK_REQUESTED indicates that a potential attack has already been reported, withdrawals are not immediately disabled. Instead, the pool only freezes withdrawals after riskWindowStart is set, which appears to occur in a separate transaction or execution path.


This creates a temporary window where the registry has already signaled risk, but users can still withdraw their entire stake before the pool records the risk locally.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact
    Users or automated bots monitoring the registry can immediately withdraw their funds as soon as the state changes to ATTACK_REQUESTED, before riskWindowStart is updated.


  • This can trigger a stake run, allowing a large portion of the pool's liquidity to leave just before the pool is expected to cover potential claims. As a result, the insurance/confidence pool may become underfunded or unable to satisfy legitimate payouts after the attack is confirmed.

Proof of Concept

contract AttackRequestedWithdrawPoC is Test {
MockAttackRegistry registry;
MockERC20 token;
VulnerablePool vulnerable;
FixedPool fixedPool;
address staker = makeAddr("staker");
uint256 constant STAKE_AMOUNT = 1_000 ether;
function setUp() public {
registry = new MockAttackRegistry();
token = new MockERC20();
vulnerable = new VulnerablePool(registry, token);
fixedPool = new FixedPool(registry, token);
vulnerable.depositForTest(staker, STAKE_AMOUNT);
fixedPool.depositForTest(staker, STAKE_AMOUNT);
}
/// @notice Demonstrates the vulnerability: staker exits cleanly during
/// ATTACK_REQUESTED, before riskWindowStart is ever set.
function test_PoC_VulnerablePool_AllowsWithdrawDuringAttackRequested() public {
// Registry flips to ATTACK_REQUESTED — attack has been flagged.
registry.setState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
// riskWindowStart is still 0 — no one has set it yet.
assertEq(vulnerable.riskWindowStart(), 0);
uint256 balBefore = token.balanceOf(staker);
vm.prank(staker);
vulnerable.withdraw(); // succeeds — should NOT have
uint256 balAfter = token.balanceOf(staker);
assertEq(balAfter - balBefore, STAKE_AMOUNT);
assertEq(vulnerable.eligibleStake(staker), 0);
assertEq(vulnerable.totalEligibleStake(), 0);
// Pool is now fully drained despite the attack being flagged and
// riskWindowStart never having been set.
assertEq(token.balanceOf(address(vulnerable)), 0);
}
/// @notice Confirms the fix: the same sequence reverts on the patched pool.
function test_PoC_FixedPool_BlocksWithdrawDuringAttackRequested() public {
registry.setState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
assertEq(fixedPool.riskWindowStart(), 0);
vm.prank(staker);
vm.expectRevert(WithdrawsDisabled.selector);
fixedPool.withdraw();
// Stake remains locked in the pool.
assertEq(fixedPool.eligibleStake(staker), STAKE_AMOUNT);
assertEq(token.balanceOf(address(fixedPool)), STAKE_AMOUNT);
}
/// @notice Sanity check: fixed pool still allows withdrawal in genuinely safe states.
function test_PoC_FixedPool_AllowsWithdrawInSafeState() public {
registry.setState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
vm.prank(staker);
fixedPool.withdraw(); // succeeds — correct behavior preserved
assertEq(fixedPool.eligibleStake(staker), 0);
assertEq(token.balanceOf(staker), STAKE_AMOUNT);
}
}

Recommended Mitigation

Treat ATTACK_REQUESTED as a withdrawal-blocking state by removing it from the list of states that permit withdrawals.
If withdrawals must remain available during ATTACK_REQUESTED for business reasons (e.g., to account for false-positive reports), ensure that riskWindowStart is set atomically in the same transaction that transitions the registry into ATTACK_REQUESTED.

This removes the observable gap that allows users to exit before the pool is frozen.

- remove this code
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// `riskWindowStart` is the pool's one-way record that risk has materialised;
// gate on it so an upstream registry rewind cannot re-open withdrawals.
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
uint256 amount = eligibleStake[msg.sender];
if (amount == 0) revert InvalidAmount();
_clampUserSums(msg.sender);
// Withdrawing forfeits the caller's bonus claim: subtract their full contribution from
// the global accumulators so honest stakers' shares aren't diluted by the exiter's
// forfeited weight.
sumStakeTime -= userSumStakeTime[msg.sender];
sumStakeTimeSq -= userSumStakeTimeSq[msg.sender];
eligibleStake[msg.sender] = 0;
userSumStakeTime[msg.sender] = 0;
userSumStakeTimeSq[msg.sender] = 0;
totalEligibleStake -= amount;
stakeToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
+ add this code
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// `riskWindowStart` is the pool's one-way record that risk has materialised;
// gate on it so an upstream registry rewind cannot re-open withdrawals.
//
// FIX: `ATTACK_REQUESTED` is no longer treated as a safe state. It signals
// that an attack has been flagged against the protected protocol and is
// pending confirmation — allowing withdrawals here would let stakers front-run
// `riskWindowStart` being set and drain the pool exactly when solvency is needed.
// Only NOT_DEPLOYED and NEW_DEPLOYMENT are genuinely risk-free states.
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT)
) {
revert WithdrawsDisabled();
}
uint256 amount = eligibleStake[msg.sender];
if (amount == 0) revert InvalidAmount();
_clampUserSums(msg.sender);
// Withdrawing forfeits the caller's bonus claim: subtract their full contribution from
// the global accumulators so honest stakers' shares aren't diluted by the exiter's
// forfeited weight.
sumStakeTime -= userSumStakeTime[msg.sender];
sumStakeTimeSq -= userSumStakeTimeSq[msg.sender];
eligibleStake[msg.sender] = 0;
userSumStakeTime[msg.sender] = 0;
userSumStakeTimeSq[msg.sender] = 0;
totalEligibleStake -= amount;
stakeToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}

Support

FAQs

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

Give us feedback!