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

`withdraw()` reopens after a registry rewind when `riskWindowStart` was never sealed, letting a staker escape full principal from a genuinely CORRUPTED pool

Author Revealed upon completion

Root + Impact

Description

  • Normally withdraw() is permanently disabled once the agreement reaches an active-risk state,
    so a staker cannot watch an attack unfold on-chain and front-run resolution to escape with full
    principal. docs/DESIGN.md §9/§11 state this is enforced by the one-way riskWindowStart != 0
    latch specifically so "a benign upstream state rewind cannot re-open withdraw."

  • That latch is only sealed by a successful pool interaction while the registry reports an
    active-risk state (_isActiveRiskState: UNDER_ATTACK/PROMOTION_REQUESTED). If the entire
    active-risk interval elapses with nobody successfully interacting with the pool, and the registry
    reaches a terminal state (CORRUPTED) directly, riskWindowStart stays 0_markRiskWindowStart
    is never called, because CORRUPTED only satisfies _isTerminalState, not _isActiveRiskState.
    A subsequent rewind to a pre-attack state (the same rewind primitive M-02 in this same pass relies
    on) then satisfies withdraw()'s gate on both clauses, and it succeeds.

// 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 successfully 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.
...
}

There's a second, sharper layer to this: an attempted withdraw while the live state is still
CORRUPTED correctly reverts (the second clause blocks it) — but that attempt does not seal
anything either, because _observePoolState()'s storage writes are made inside the same call frame
that reverts moments later, and Solidity rolls back all storage writes from a reverted call, not
just the ones after the revert point. I confirmed this directly: calling withdraw() once while
riskWindowEnd == 0 and the live state is CORRUPTED reverts as expected, but riskWindowEnd
reads back as 0 immediately afterward, exactly as if the call never happened. So even a staker's
own blocked attempt during the breach doesn't help close the gap for anyone.

Risk

Likelihood:

  • The active-risk interval elapses with no pool interaction that succeeds, so riskWindowStart is
    never sealed. This isn't a contrived edge case: it's the exact precondition M-02 in this same
    pass already establishes is realistic (nobody happens to interact with a given pool during a
    specific registry-state window), and a blocked withdraw attempt during the terminal state
    doesn't help either, per the rollback behavior above — so the actual window is wider than "nobody
    interacts," it's "nobody's interaction succeeds."

  • A benign registry migration/rewind reports a pre-attack state after risk materialized — the same
    rewind primitive M-02's registry-reopens-staking finding depends on, so this threat is already
    in-model for this codebase (docs/DESIGN.md §11 discusses registry migrations as a real,
    anticipated event).

  • A staker monitoring registry state off-chain calls withdraw() during the rewound pre-attack
    window, before the moderator finalizes CORRUPTED or before anyone else happens to call
    pokeRiskWindow().

Impact:

  • The escaping staker recovers full principal from a pool whose agreement is genuinely CORRUPTED
    in scope — principal that the CORRUPTED resolution is supposed to sweep to recoveryAddress,
    directly defeating the pool's core insurance guarantee for that staker.

  • The loss is borne unequally: co-stakers who don't happen to notice or time the rewind still lose
    their principal to the sweep, while the escaping staker takes theirs risk-free. This directly
    contradicts the explicit guarantee in docs/DESIGN.md §9 and §11 that a rewind "cannot re-open
    withdraw."

  • Rated Medium/High rather than a straightforward High because it requires the upstream rewind
    precondition (external to the pool, not attacker-triggerable on demand) — the same likelihood
    caveat M-02 carries — but the impact here is more severe than M-02's bonus misallocation: this is
    direct principal escape from a confirmed breach, not a redistribution within the bonus pool.

Proof of Concept

Checked into the repo as a real, runnable file at
test/unit/ConfidencePool.withdrawRewindEscape.t.sol
(5 tests: the exploit, the rollback root-cause confirmation, two controls, and a demonstration of
what a correct fix needs to rely on):

forge test --match-contract ConfidencePoolWithdrawRewindEscapeTest -vv
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // nobody poked
assertEq(pool.riskWindowStart(), 0);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector); // live terminal state still blocks it
pool.withdraw();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT); // benign rewind
vm.prank(alice);
pool.withdraw(); // escapes full principal after an in-scope breach
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector); // recovery gets nothing
pool.claimCorrupted();
[PASS] testPoC_WithdrawReopensAfterCorruptedWhenRiskWindowUnsealed() (gas: 379537)
[PASS] testPoC_RevertedWithdrawDoesNotPersistObservation() (gas: 321813)
[PASS] testPoC_Control_NoRewind_CorruptedSweepsPrincipalToRecovery() (gas: 446446)
[PASS] testPoC_Control_SealedRiskWindowKeepsWithdrawClosedAcrossRewind() (gas: 323042)
[PASS] testPoC_PokeRiskWindowDuringCorruptedWouldSurviveRewindIfGatedOn() (gas: 320099)

Two controls isolate the cause precisely: with no rewind, recovery correctly receives the full
100-token principal; with the same rewind but riskWindowStart sealed via a prior pokeRiskWindow()
call during UNDER_ATTACK, the identical withdraw attempt correctly stays blocked.

Recommended Mitigation

A candidate fix was tested and found insufficient before landing on the one below — worth
recording so it isn't proposed again. Adding a new withdrawDisabled flag set inside
_observePoolState()'s scope-lock branch, and gating withdraw() on it, looks reasonable but fails
for two independently-confirmed reasons:

  1. It doesn't actually close the gap in the exact PoC scenario above: the flag would only get set
    by a call to _observePoolState() that survives to completion, and the one call that observes
    CORRUPTED in the PoC (the blocked withdraw attempt) reverts, rolling the flag back with it.
    Verified directly: applying this fix, testPoC_WithdrawReopensAfterCorruptedWhenRiskWindowUnsealed
    still passes (alice still escapes).

  2. It introduces a regression: the scope-lock branch fires on any state past NEW_DEPLOYMENT,
    including ATTACK_REQUESTED, where docs/DESIGN.md §9 explicitly says withdraw should still be
    open. Verified directly: with this fix applied, a legitimate withdraw during ATTACK_REQUESTED
    reverts WithdrawsDisabled where it shouldn't.

The fix that actually holds up under both checks: gate withdraw() on riskWindowEnd != 0 too, not
a new flag. riskWindowEnd can be reliably sealed independent of withdraw() itself, because
pokeRiskWindow() is a call that succeeds regardless of live state (it only reverts if nothing has
ever been sealed) — so if anyone permissionlessly pokes while the registry is terminal, the
observation survives a later rewind:

if (
- riskWindowStart != 0
+ riskWindowStart != 0 || riskWindowEnd != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

Verified this version against all three cases: it blocks the exploit whenever anyone pokes during
the terminal window before the rewind, it does not regress ATTACK_REQUESTED withdrawals, and
the full existing test suite (268 other tests) still passes with it applied.

Residual, disclosed rather than hidden: if truly nobody — not even a single permissionless poke
— ever successfully interacts with the pool during the entire window between the active-risk state
and the rewind, no latch-based fix can help, because Solidity cannot persist a storage write from a
call that never happens. This is a fundamental limit of the lazy, pull-based observation model, not
something this specific fix leaves on the table. Closing it completely would require either a
keeper/automation guarantee that pokeRiskWindow() gets called promptly on every terminal
transition, or restructuring resolution to not depend on any successful prior observation at all.

Support

FAQs

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

Give us feedback!