claimExpired() anchors MODERATOR_CORRUPTED_GRACE to the pool's fixed expiry, even though the
pool moderator cannot classify a corruption until the registry actually enters CORRUPTED:
Consequently, the entire grace period can elapse while both moderator outcomes are invalid. If the
registry first transitions from UNDER_ATTACK to CORRUPTED after expiry + grace, an arbitrary
caller can immediately backrun that legitimate transition with claimExpired(). The call
permanently finalizes bad-faith CORRUPTED and sets claimsStarted = true, giving a continuously
available pool moderator zero seconds to decide:
whether the breach was inside this pool's committed scope; and
whether an in-scope breach was good-faith and should pay the named whitehat.
For an out-of-scope or good-faith breach, the permissionless call therefore selects the wrong
economic recipient. claimCorrupted() sends every staker's principal and the entire bonus to the
sponsor-selected recoveryAddress; the moderator can no longer correct the outcome.
The auto-CORRUPTED branch is:
The deadline is independent of the first time state == CORRUPTED can be true. This permits the
following state sequence:
Stakers deposit and the pool observes UNDER_ATTACK, setting riskWindowStart != 0.
The agreement remains UNDER_ATTACK past pool.expiry() + MODERATOR_CORRUPTED_GRACE.
During that period, flagOutcome(SURVIVED) and flagOutcome(CORRUPTED) both revert because
UNDER_ATTACK is not a terminal registry state.
The registry legitimately transitions to CORRUPTED.
Any account calls claimExpired() in the same block or immediately afterward.
Since the expiry-anchored deadline is already in the past, the pool finalizes bad-faith
CORRUPTED without giving the moderator an actionable grace period.
claimsStarted = true prevents every later moderator correction.
The upstream state machine permits markCorrupted() from UNDER_ATTACK and
PROMOTION_REQUESTED, and UNDER_ATTACK has no automatic terminal deadline. No false registry
state or malicious dependency behavior is required.
The exploitable pool call is also completely permissionless. The upstream attack moderator may be
performing the correct, ordinary action by recording a real corruption; an unrelated account can
backrun that transaction before the pool moderator has any valid opportunity to classify the
pool-specific outcome. The finding therefore does not depend on treating a trusted administrator's
incorrect input as an exploit.
The numerical expiry + 180 days anchor is documented. The issue is the unhandled state sequence
in which that deadline passes before the moderator's action precondition becomes satisfiable.
docs/DESIGN.md section 6 justifies scope-blind auto-CORRUPTED settlement as a backstop when the
moderator is unavailable for the full grace window. Here the moderator may be continuously online,
but cannot flag either terminal outcome during the elapsed window because the registry remains
UNDER_ATTACK. The first instant at which the moderator can make the required scope/good-faith
decision is also the first instant at which any caller can permanently bypass it.
This report does not dispute that a scope-blind fallback is intentional after genuine moderator
unavailability. It identifies that the implementation starts the fallback timer before the
moderator can act, so expiration of the timer does not prove the absence that is supposed to
justify the fallback.
The pool must remain unresolved until after expiry + MODERATOR_CORRUPTED_GRACE. A caller could
have finalized EXPIRED earlier, but the protocol imposes no keeper obligation and unresolved
pools remain valid on-chain.
The registry must first enter CORRUPTED after the fixed deadline. The upstream state machine
explicitly permits this sequence.
Exploitation after that transition is permissionless and immediate; the caller does not need to
control the pool, registry, moderator, or recovery address.
In the affected out-of-scope case, stakers lose all principal and bonus even though SURVIVED
was the correct pool-specific outcome.
In the affected good-faith case, the named whitehat loses the entire bounty because the pool is
irreversibly classified as bad-faith.
The first auto-resolution sets claimsStarted = true, so the legitimate moderator cannot repair
the result even if it submits its transaction immediately afterward.
The long dormant-pool and late-transition preconditions reduce likelihood, but the impact is the
full pool balance and the race is deterministic once those conditions exist.
Place the following test in test/audit/ConfidencePoolPostExpiryCorruption.t.sol:
Run:
Result:
The PoC proves that the moderator cannot act before the late transition, an unprivileged caller can
finalize immediately afterward, the moderator's correction then reverts, and the entire pool
balance is transferred to recoveryAddress.
Anchor the emergency fallback to the first successful pool interaction that observes
CORRUPTED. The first claimExpired() observation must return rather than revert, because a revert
would roll back the newly recorded timestamp.
One implementation is:
If pokeRiskWindow() is the first successful interaction to observe CORRUPTED, the timestamp is
persisted there. If claimExpired() is the first observer, observedBefore == 0 makes it return
successfully so the timestamp is not rolled back. The moderator then receives the full actionable
grace period. If it remains unavailable after that period, the existing permissionless
bad-faith-CORRUPTED fallback still operates.
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.