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.
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.
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.
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):
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.
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:
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).
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:
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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.