pokeRiskWindow() remains callable after a pool expires. If the agreement first enters an active-risk state after expiry, any caller can use this function to set riskWindowStart = expiry. That post-term marker later satisfies the riskWindowStart != 0 condition in claimExpired(), allowing the pool to resolve as bad-faith CORRUPTED instead of EXPIRED and causing stakers to lose all principal.
A confidence pool covers a fixed term ending at expiry. Stakers can no longer deposit at that timestamp, and claimExpired() becomes available to settle the pool. If no active-risk state was observed during the term, riskWindowStart remains zero. This zero value is security-critical because the permissionless auto-CORRUPTED backstop in claimExpired() is available only when an observed risk window exists.
The relevant condition is in src/ConfidencePool.sol:532:
When riskWindowStart == 0, a terminal CORRUPTED registry state does not enter this mechanical backstop. claimExpired() instead follows the documented no-risk-window behavior and resolves the pool as EXPIRED, returning principal to stakers. This conservative branch is necessary because the permissionless resolver cannot determine whether agreement-level corruption affected the pool's local scope.
However, pokeRiskWindow() has no expiry check. It only checks whether the pool is unresolved:
_observePoolState() opens the risk window whenever it sees UNDER_ATTACK or PROMOTION_REQUESTED, regardless of whether the pool term has ended:
_markRiskWindowStart() caps a late observation to expiry, but still stores a nonzero marker:
The timestamp cap protects bonus arithmetic from recording time beyond the pool term, but it does not preserve the meaning of riskWindowStart. After the write, the contract cannot distinguish an active-risk observation made during the covered term from one first made after the term ended. Both are represented by the same nonzero value and both satisfy the auto-CORRUPTED gate.
The resulting sequence is:
Stakers deposit principal, and the pool reaches expiry without observing UNDER_ATTACK or PROMOTION_REQUESTED.
The pool remains unresolved. This is supported because expired claims have no timeout.
After expiry, the agreement enters UNDER_ATTACK.
An arbitrary caller invokes pokeRiskWindow(), which records riskWindowStart = expiry even though the active-risk state was first observed after the covered term.
The agreement later enters CORRUPTED before a staker resolves the pool as EXPIRED.
During MODERATOR_CORRUPTED_GRACE, claimExpired() now reverts with AgreementCorruptedAwaitingModerator because the late marker satisfies riskWindowStart != 0.
At expiry + MODERATOR_CORRUPTED_GRACE, any caller invokes claimExpired(). The pool mechanically resolves as bad-faith CORRUPTED and permanently sets claimsStarted = true.
claimCorrupted() transfers the entire pool balance, including all staker principal, to recoveryAddress.
This breaks the term-bound settlement guarantee. A public observation made after the underwriting period changes an accrued EXPIRED principal claim into a principal-forfeiting CORRUPTED outcome.
This issue is distinct from the documented permissionless observation and first-observation timing behavior. The accepted timing residual changes the distribution of a fixed bonus pool. Here, the late observation changes the principal settlement branch. It is also distinct from the accepted scope-blind auto-CORRUPTED backstop because that backstop assumes an observed risk window exists; this issue allows an untrusted caller to create the only risk marker after the pool term has ended.
The documented zero-score fallback does not cover this behavior either. docs/DESIGN.md:198-205 describes amount-weighted bonus distribution when an observed window has no measurable duration. It does not authorize a post-term observation to satisfy the separate principal-forfeiture condition in claimExpired().
The impact is rated Medium.
The PoC demonstrates complete loss of staker principal. Alice and Bob deposit a combined 150 tokens. Without the post-expiry poke, the same registry sequence resolves the pool as EXPIRED, and both stakers recover their full principal. With the post-expiry poke, the pool resolves as bad-faith CORRUPTED, and the full 180-token balance is transferred to recoveryAddress; Alice and Bob receive nothing.
The tracked bonus is also included in the CORRUPTED sweep, but the severity assessment relies on the demonstrated principal loss. In the no-observed-risk control path, the bonus is not owed to stakers under the protocol's documented rules.
The loss is fully in scope:
The vulnerable state transitions occur in ConfidencePool.
The exploit uses a standard ERC20.
The triggering functions are permissionless.
No trusted role, compromised administrator, malicious registry, or unsupported token behavior is required.
The principal loss is final after mechanical CORRUPTED resolution sets claimsStarted = true and claimCorrupted() drains the pool.
The issue is not rated High because exploitation requires a pool to remain unresolved after expiry, a post-expiry active-risk interval, a subsequent CORRUPTED state, and moderator inactivity through the configured grace period.
The likelihood is Medium-Low because the attack requires a specific but supported lifecycle sequence.
The required conditions are:
The pool expires without an in-term active-risk observation.
No participant immediately calls claimExpired().
The agreement enters an active-risk state after pool expiry.
A public caller invokes pokeRiskWindow() before a participant resolves the pool.
The registry reaches CORRUPTED before the pool is resolved as EXPIRED.
The moderator does not finalize the appropriate pool-level outcome during the 180-day grace period.
These conditions do not rely on invalid configuration. Claims intentionally have no timeout, and pokeRiskWindow() is intentionally permissionless. The caller spends no protocol capital and needs no privileged role. Once the registry reaches CORRUPTED, stakers cannot protect their principal through claimExpired() during the grace period because the late marker causes that function to revert. The long grace period lowers frequency, while the complete principal loss makes the resulting risk material.
Add the following test as test/unit/AuditLateCorruptionPoC.t.sol:
Run the PoC with:
Observed result:
The control test establishes that the registry sequence alone does not cause the loss. The exploit test differs only by the post-expiry pokeRiskWindow() call and ends with all principal transferred to recovery.
Do not allow pokeRiskWindow() to record new observations after the pool term has ended. Post-expiry callers should use claimExpired(), which observes the live registry state and resolves the pool atomically instead of leaving a post-term marker in an unresolved pool.
A direct fix is:
Using >= matches the existing lifecycle boundary: staking closes and claimExpired() opens at expiry.
Alternatively, preserve post-expiry terminal observation while preventing only the dangerous state transition: when block.timestamp >= expiry && riskWindowStart == 0, _observePoolState() must not call _markRiskWindowStart(). The implementation must ensure no public path can persist the first active-risk marker after expiry and later reuse it as the auto-CORRUPTED principal-forfeiture gate.
Add regression coverage for both properties:
A post-expiry active-risk poke cannot change riskWindowStart from zero.
A later terminal CORRUPTED state with no in-term risk observation follows the documented no-risk-window EXPIRED branch and preserves staker principal.
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.