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

Withdrawals ignore the persisted terminal-state latch (`riskWindowEnd`)

Author Revealed upon completion

Root + Impact

Description

riskWindowStart and riskWindowEnd are independent one-way latches: the
first records an observed active-risk state, the second a observed terminal
state. withdraw()'s gate checks only riskWindowStart and the live
registry state — it never checks riskWindowEnd.

If the pool's first observation lands directly on a terminal state
(PRODUCTION/CORRUPTED) with no prior active-risk observation,
riskWindowEnd latches while riskWindowStart stays 0. If the registry then
reports/rewinds to NOT_DEPLOYED, NEW_DEPLOYMENT, or ATTACK_REQUESTED,
withdraw() passes — even though a terminal state was already recorded and
the pool should resolve via claimExpired/flagOutcome instead.

// withdraw() — src/ConfidencePool.sol:290
if (
@> riskWindowStart != 0 // stays false in this scenario
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) revert WithdrawsDisabled();
@> // riskWindowEnd never checked
// _observePoolState() — src/ConfidencePool.sol:793
if (riskWindowStart == 0 && _isActiveRiskState(state)) _markRiskWindowStart();
@> if (riskWindowEnd == 0 && _isTerminalState(state)) _markRiskWindowEnd(); // latches independently

Risk

Likelihood:

  • Occurs whenever the pool's first observed state is already terminal —
    possible whenever no one interacts with the pool during the active-risk
    interval, an acknowledged scenario per §5/§6.

  • Permissionless: pokeRiskWindow() is the documented way this latch gets
    set, so it happens through normal expected usage.

Impact:

  • Lets a staker withdraw principal after a terminal state (including
    CORRUPTED) is already recorded, bypassing the claim paths meant to govern
    post-resolution behavior.

  • Defeats the exact escape §9 says withdraw()'s lock prevents: exiting
    after observing an attack, ahead of flagOutcome.

  • Skews remaining accounting for stakers who don't exit early.

Proof of Concept

Shows a terminal observation latching riskWindowEnd, then withdraw()
succeeding on a later call because it never checks that latch.

// 1. First-ever observation is terminal (e.g. PRODUCTION), no prior active-risk state
pool.pokeRiskWindow();
// riskWindowStart == 0, riskWindowEnd != 0, outcome still UNRESOLVED
// 2. Registry reports/rewinds to ATTACK_REQUESTED
// 3. Staker withdraws
pool.withdraw();
// riskWindowStart == 0 -> passes; state == ATTACK_REQUESTED -> passes
// Withdrawal succeeds despite riskWindowEnd != 0

Recommended Mitigation

if (
riskWindowStart != 0
+ || riskWindowEnd != 0
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) revert WithdrawsDisabled();

Alternative: treat riskWindowStart != 0 || riskWindowEnd != 0 as one
combined observation latch everywhere withdraw()-eligibility is checked.

Support

FAQs

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

Give us feedback!