Normal behavior: once the pool observes UNDER_ATTACK, withdrawals should remain permanently disabled. The local riskWindowStart latch is intended to prevent a later registry replacement/rewind from reopening withdrawals.
I observed that withdraw() calls _observePoolState() first. When the live state is UNDER_ATTACK, _observePoolState() sets riskWindowStart. But withdraw() then reverts with WithdrawsDisabled(). Because the whole transaction reverts, the riskWindowStart write is rolled back. If the registry is later rewound/replaced to NEW_DEPLOYMENT, withdraw() succeeds and the staker escapes principal that should remain available for CORRUPTED settlement.
The issue is that the one-way riskWindowStart observation is written inside a call path that later reverts for the same observed state. The revert cancels the latch.
Likelihood:
This occurs when a staker calls withdraw() while the registry state is UNDER_ATTACK, before any successful pokeRiskWindow() or other successful call persists riskWindowStart.
A later registry replacement, migration, or rewind to a pre-attack state such as NEW_DEPLOYMENT makes withdraw() pass because riskWindowStart is still zero.
Impact:
A staker can withdraw principal after the agreement already reached active risk.
If the pool later resolves CORRUPTED, the escaped principal is no longer available for the recovery address or good-faith attacker bounty.
Create this file:
Run:
Observed output:
The control test proves that a persisted riskWindowStart correctly keeps withdrawals disabled after a registry rewind.
The exploit test proves that a failed withdraw() during UNDER_ATTACK rolls back the latch, allowing Alice to withdraw 100e18 principal after a rewind. Later CORRUPTED settlement can only sweep the remaining 50e18 bonus.
Do not perform one-way lifecycle observations inside a branch that will later revert and roll back the observation.
One direct fix is to make withdraw() explicitly persist the active-risk latch before returning/reverting disabled status, or to make the permissionless observer path responsible for persisting latches and avoid using reverting state-gated functions as observers.
A minimal direction is:
A cleaner fix is to add a non-reverting internal observation path that persists riskWindowStart and then blocks withdrawal without reverting, or to require pokeRiskWindow() to be the only function that records one-way lifecycle latches. The key invariant is that once UNDER_ATTACK is observed, riskWindowStart must remain nonzero even if the user action that observed it cannot proceed.
Impact - Low The staker keeps their principal, so what is lost is the premium they were accruing while locked, and it ends up at the sponsor's recoveryAddress. DESIGN.md #9 justifies the whole no-observed-risk rule by pairing two events, saying a staker forfeits the exit exactly when the premium starts. One call does the first and undoes the second. Likelihood - Low Any staker can cure this for free with pokeRiskWindow, which succeeds in the same state the withdrawal failed in. A permanent loss therefore needs the whole cohort to miss that step, and the trap is that the obvious move is the one that fails. A short active-risk interval can also close before anyone gets a block in which to poke. DESIGN.md #6 explains an unsealed window as nobody having interacted with the pool, which is exactly what did not happen here.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.