When withdraw() is the first pool interaction to observe active risk, it writes riskWindowStart and then reverts, rolling the supposedly permanent latch back and allowing a later registry rewind to reopen withdrawal.
Withdrawals are intended to be available only in NOT_DEPLOYED, NEW_DEPLOYMENT, and ATTACK_REQUESTED, then permanently disabled from UNDER_ATTACK onward. The pool deliberately supplements its live registry check with the one-way riskWindowStart latch so a legitimate upstream registry replacement or temporary state rewind cannot reopen the exit.
withdraw() first calls _observePoolState(). When the live state is UNDER_ATTACK or PROMOTION_REQUESTED, the observer sets riskWindowStart, resets the global bonus moments, and emits the risk-window event. The withdrawal gate then sees the newly written latch and reverts WithdrawsDisabled.
EVM reverts are atomic. The revert unwinds not only the withdrawal, but also riskWindowStart, scopeLocked, the moment resets, and their events. Consequently, the withdrawal that first discovers active risk cannot preserve the historical fact needed to make its rejection permanent.
If no other successful entrypoint calls _observePoolState() before the trusted registry pointer is replaced or migrated to an instance temporarily reporting a pre-risk state, the next withdrawal sees both:
It therefore succeeds and deletes the staker's principal from pool accounting. When the replacement registry catches up and the moderator resolves CORRUPTED, the escaped stake is absent from snapshotTotalStaked, corruptedReserve, and any attacker bounty. The staker avoids the distribution they were intended to be irrevocably committed to from the first active-risk observation onward.
The existing permissionless pokeRiskWindow() reduces exposure but does not enforce the invariant. It helps only when a separate successful transaction is included before the rewind. The natural first interaction can be the staker's failed withdrawal itself, and the protocol does not guarantee a keeper will persist the window first.
Moving the latch assignment earlier inside withdraw() cannot fix the issue. Any write made anywhere in the same call frame is reverted when withdraw() reverts.
Likelihood:
BattleChain's ordinary registry lifecycle does not transition from active risk back to a pre-risk state. Exploitation requires a registry replacement, migration, reset, or equivalent trusted infrastructure event to present a pre-risk state while the pool is still unresolved.
withdraw() must be the first pool interaction that observes active risk. Any successful pokeRiskWindow(), active-risk stake, bonus contribution, or other non-reverting observer persists riskWindowStart and closes this path.
Once those conditions occur, any staker can exploit the reopened exit without privilege, unusual token behavior, or control over outcome resolution.
Impact:
A staker can recover their entire eligible principal after the documented point at which that principal became permanently committed to resolution.
The escaped amount is deleted from totalEligibleStake and omitted from the later outcome snapshot. This reduces corruptedReserve for bad-faith corruption or bountyEntitlement for good-faith corruption by the full withdrawn amount.
The PoC demonstrates Alice escaping with half of the pool while the later CORRUPTED recovery sweep receives only Bob's remaining half.
Create test/audit/CP004WithdrawalRiskLatchRollback.t.sol with the following contents:
Run the PoC from the repository root:
Execute forge test --offline --match-path test/audit/CP004WithdrawalRiskLatchRollback.t.sol -vv.
Confirm that the test passes and the suite reports one test passed with zero failures.
A pool-local fix must allow the first disallowed withdrawal call to complete successfully without transferring principal, so its monotonic lock can persist. Make this outcome explicit through both a return value and an event; later attempts can retain the existing revert behavior.
Checking riskWindowEnd as well prevents a persisted terminal observation with no active-risk start from being ignored after a rewind, while keeping the bonus-accounting meaning of riskWindowStart unchanged.
All callers and frontends must handle the new false result/event as a rejected withdrawal. If revert-on-rejection semantics must be preserved for every call, a self-contained pool fix is impossible: the trusted registry or migration layer must instead expose an irreversible everReachedActiveRisk/highest-state witness that is preserved across registry replacements. withdraw() can query that durable history and revert without relying on a write made in its own reverting transaction.
As a weaker operational mitigation, require every registry migration to successfully call pokeRiskWindow() for every unresolved pool before changing the pointer. This introduces a keeper-completeness dependency and should not be described as autonomous rewind protection.
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.