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.
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.