After a pool expires, claimExpired() is intended to mechanically resolve the pool. When the registry is still in an active-risk state such as UNDER_ATTACK or PROMOTION_REQUESTED, the pool resolves EXPIRED and returns principal because the agreement survived the underwritten term.
However, any caller can avoid resolving EXPIRED and call pokeRiskWindow() instead. pokeRiskWindow() observes the same active-risk state, caps riskWindowStart to expiry, and leaves the pool unresolved. Later, when the registry becomes CORRUPTED, the now nonzero riskWindowStart satisfies the auto-CORRUPTED gate in claimExpired(). After the expiry-anchored grace period, claimExpired() finalizes bad-faith CORRUPTED, sets claimsStarted, blocks moderator correction, and allows claimCorrupted() to sweep the full pool to recoveryAddress.
This changes the outcome for the same expired pool from EXPIRED to CORRUPTED solely because a permissionless caller used pokeRiskWindow() after expiry.
The permissionless marker entrypoint ConfidencePool.sol::pokeRiskWindow observes active risk without resolving the expired pool:
The post-expiry active-risk observation is capped but still persisted in ConfidencePool.sol#L793-L810:
The auto-CORRUPTED branch in ConfidencePool.sol::claimExpired treats any nonzero risk window as eligible for bad-faith finalization:
The direct expired path in ConfidencePool.sol::claimExpired would otherwise settle the same expired pool as EXPIRED:
After bad-faith finalization, ConfidencePool.sol::claimCorrupted can sweep the remaining balance to recovery:
Likelihood: Medium
A pool expires unresolved with no earlier active-risk observation.
After expiry, the registry is observed in an active-risk state and a caller uses pokeRiskWindow() instead of claimExpired().
The registry later becomes CORRUPTED, and the moderator does not flag an outcome before expiry + MODERATOR_CORRUPTED_GRACE.
Impact: High
Stakers who would otherwise receive EXPIRED principal can lose all principal and bonus to the sponsor-controlled recoveryAddress.
The mechanical auto-CORRUPTED path sets claimsStarted, which blocks moderator correction and prevents good-faith attacker naming.
The PoC file included with this report is:
To run it in a fresh contest checkout:
Copy PostExpiryPokeAutoCorruptedPoC.t.sol into the repository's test/ directory.
Run:
Expected result:
The PoC demonstrates the exploit path:
Alice stakes 100 tokens and a bonus contributor adds 20 tokens.
The pool expires.
The registry enters UNDER_ATTACK after expiry.
An untrusted caller calls pokeRiskWindow(), setting riskWindowStart to expiry while leaving the pool unresolved.
The registry later becomes CORRUPTED.
After the grace period, claimExpired() finalizes bad-faith CORRUPTED and sets claimsStarted.
The moderator can no longer correct the outcome.
claimCorrupted() sweeps all 120 tokens to recoveryAddress.
Inline PoC source (PostExpiryPokeAutoCorruptedPoC.t.sol):
Prevent post-expiry marker-only risk observations from changing future resolution. The simplest fix is to make pokeRiskWindow() resolve or revert after expiry instead of setting riskWindowStart on an expired unresolved pool.
For example:
Alternatively, keep pokeRiskWindow() callable after expiry but do not mark riskWindowStart from post-expiry active-risk observations. In that model, an expired pool's first post-expiry interaction should go through claimExpired() so the outcome is resolved atomically rather than leaving a marker that changes a later branch.
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.