The k=2 bonus score uses T = outcomeFlaggedAt (= riskWindowEnd for SURVIVED/CORRUPTED, or expiry for EXPIRED) as the upper bound of the at-risk time interval. Every staker's score is stake × (T − entryTime)². <T's upper bound is meant to be a hard ceiling: stakes entered after T should not accrue time-weighted bonus credit because the risk interval has already closed.
Two latches are supposed to protect this invariant:
riskWindowStart latches once on first observation of UNDER_ATTACK/PROMOTION_REQUESTED (_observePoolState:793). DESIGN §9 + the existing regression test testWithdrawRemainsDisabledAfterRegistryRewind certify that a benign upstream registry rewind cannot re-open withdraw against this latch.
riskWindowEnd latches once on first observation of PRODUCTION/CORRUPTED (_observePoolState:796). The contract natspec (ConfidencePool.sol:108-113) documents this as the upper bound anchored at the first terminal moment.
stake() checks neither the registry state in the rewind scenario nor riskWindowEnd. Its deposit gate is only _assertDepositsAllowed(state) (ConfidencePool.sol:727), which blocks PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED. A registry that was once PRODUCTION and is benignly re-pointed (DESIGN §11 line 284-287 explicitly contemplates "a legitimate DAO registry migration") to a fresh registry reporting NEW_DEPLOYMENT/ATTACK_REQUESTED/NOT_DEPLOYED/UNDER_ATTACK re-opens stake() — with riskWindowEnd still pinned to the old T.
The entry-time clamp in stake() (ConfidencePool.sol:248-250) only floors newEntry up to riskWindowStart:
There is no ceiling against riskWindowEnd. A late staker entering with newEntry > riskWindowEnd writes userSumStakeTime[u] = stake × newEntry and userSumStakeTimeSq[u] = stake × newEntry² where newEntry > T. The k=2 score T²·stake − 2T·(stake·newEntry) + stake·newEntry² simplifies to stake × (newEntry − T)² — strictly positive and growing-parabolic with post-terminal delay.
The in-contract _clampUserSums (ConfidencePool.sol:677-685)perator has the same blind spot: it floors up to riskWindowStart, never ceilings down to riskWindowEnd. The snapshot taken in flagOutcome / claimExpired (ConfidencePool.sol:357-360, 524-527) thus freezes the late staker's inflated score verbatim, and _bonusShare (ConfidencePool.sol:696-720) divides it into the full snapshotTotalBonus as if the late staker had carried the highest at-risk exposure.
The finding is realized via either of two distinct terminal-resolution call paths:
Moderator path — after the rewind, the registry migrates back to PRODUCTION; the moderator calls flagOutcome(SURVIVED, false, address(0)) (ConfidencePool.sol:330). outcomeFlaggedAt = riskWindowEnd = the original frozen T. The late staker's userScore is computed against T while their entryTime > T.
Permissionless path — at block.timestamp ≥ expiry, anyone (including the attacker themselves) calls claimExpired() (ConfidencePool.sol:511). The auto-resolution branch at line 557 fires because the registry state is PRODUCTION; outcomeFlaggedAt = riskWindowEnd = T (line 559). The staker never needed the moderator at all.
The bypass of MODERATOR_CORRUPTED_GRACE (DESIGN §6) does NOT apply here — this is the SURVIVED branch, which has no grace gate. The attacker does not depend on moderator absence; they only need the registry to be (re) at PRODUCTION at the moment of claimExpired.
DESIGN §11 (lines 279-282) explicitly considers benign registry migration + benign rewind and makes a hard promise:
"No funds can be stolen; the worst case is a recoverable liveness delay or principal returned to stakers rather than swept."
This finding violates that promise: the attacker is an ordinary permissionless user, not the DAO. The DAO does not need to misbehave — it only needs to perform the documented "legitimate DAO registry migration" path (DESIGN §11 line 286). After the migration, a routine pool state (PRODUCTION + frozen riskWindowEnd) is exploited by a routine post-expiry claimExpired() call.
The §11 warrant "no funds can be stolen" is the exact invariant the contract fails to hold — bounty-eligible bonus is silently redirected from Alice (the honest pre-terminal staker who carried real risk) to Bob (the post-terminal dust staker).
Trigger requires a benign registry rewind (the project's own words: "legitimate DAO registry migration" per §11). The contract's existing regression test (testWithdrawRemainsDisabledAfterRegistryRewind) corroborates that this scenario is in-scope of the design intent — they just close the withdraw side and miss the symmetric stake side.
The pool must remain UNRESOLVED past riskWindowEnd. This is common: until expiry, no permissionless settlement exists, and the moderator may defer any flag-action until expiry approaches.
The attacker needs to know the rewind is in flight and stake in the post-terminal pre-expiry window. This is plausible for any regular participant watching on-chain state — riskWindowEnd and safeHarborRegistry.getAttackRegistry() are public.
The attacker stakes dust: 1% of the honest cohort's stake. Captured bonus: ~89%. asymmetric extraction of value via registry governance action — not exotic, the attacker does not need to influence governance at all, they only need to observe it.
Direct steal of (almost) the entire bonus pool from honest pre-terminal stakers by a late attacker holding a tiny fraction of the stake. Verified via exact integer reproduction of _bonusShare math:
Alice (100e18 staked, risk carried 1 day before T): receives 10.6270e18 of the 100e18 bonus pool (10.63%).
Bob (1e18 staked, 0 days before T, entered 29 days after T): receives 89.3730e18 of the 100e18 bonus pool (89.37%). Bob's k=2 score is 8.41× Alice's despite holding 1% of the stake and 0% of the genuine at-risk time.
The stolen value is snapshotTotalBonus — contributor capital (Carol's contributeBonus), not Alice's principal. Alice's principal is returned intact, but her risk premium — the entire reason stakers lock funds — is gone.
Reproducible via both flagOutcome(SURVIVED) and claimExpired's auto-SURVIVED branch. Two resolution paths, identical exploit.
Applicable to EXPIRED path too: a CORRUPTED out-of-scope breach that the moderator flags as SURVIVED (per DESIGN §8 line 222-227) hits the same outcomeFlaggedAt = riskWindowEnd latched T — the full bonus-redistribution attack applies.
No upper bound on attacker leverage: the further post-terminal the late stake (capped only by expiry − T), the larger (T − newEntry)² grows. A late staker allowed to enter say 60 days after T would capture ~99.99% of the bonus pool with the same 1% stake ratio. The k=2 design that was supposed to "crush late entrants" (DESIGN §7 line 170) becomes an amplifier for the late entrant when the late entrant's entry is post-terminal.
Full Foundry test in test/audit/RegistryRewindPostTerminalBonusTheft.t.sol. Two test functions demonstrate both resolution paths:
Run: forge test --match-contract RegistryRewindPostTerminalBonusTheft -vvv
The integer model (reproduced outside Solidity, matching _bonusShare line 704-719 exactly):
alice_score = T²·100e18 + 100e18·alice_entry² − 2T·100e18·alice_entry = 7.46e29
bob_score = T²·1e18 + 1e18·bob_entry² − 2T·1e18·bob_entry = 6.28e30
bob_score / alice_score = 8.41×
alice_share = 10.6270e18 bob_share = 89.3730e18
The symmetric counterpart of the existing riskWindowStart-vs-withdraw latch (DESIGN §9 + the testWithdrawRemainsDisabledAfterRegistryRewind regression). Two minimal, behavioral-preserving fixes:
stake()'s entry time against riskWindowEnd (preferred)This mirrors the floor logic on the upper side. The post-terminal stake's newEntry is clamped down to T, so its k=2 score = T²·stake − 2T·(stake·T) + stake·T² = 0 — i.e. the late entrant correctly earns zero bonus (they contributed zero at-risk time before the window closed).
stake() outright once any terminal observation has latchedStrictly stronger — once the registry ever reached PRODUCTION/CORRUPTED, no further deposits are accepted even after a rewind. This aligns with DESIGN §11's "liveness delay or principal returned" framing (no fresh capital enters a pool whose risk window already closed).
Option A is preferred because it preserves the staker's ability to add principal (without bonus) up to expiry, which may be operationally useful; Option B is preferred by reviewers who want a hard behavioural lock once the registry reaches terminal state. Either closes the theft vector.
_clampUserSums for defense-in-depthThis is only necessary if stake() is changed via Option B (which blocks new stakes globally but does not retroactively clamp already-placed late stakes); with Option A applied, _clampUserSums does not need the ceiling because new entries are clamped at the stake() boundary.
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.