FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

Benign registry rewind re-opens `stake()` after a terminal `riskWindowEnd` observation, letting a late attacker with 1% of the stake capture ~89% of the bonus pool

Author Revealed upon completion

Description

Normal Behavior

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.

Specific Issue

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:

uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start; // floor only — no ceiling against T

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.

Two independent resolution paths exploited

The finding is realized via either of two distinct terminal-resolution call paths:

  1. 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.

  2. 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.

Why this is not "trusted-DAO out-of-model"

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).


Risk

Likelihood

  • 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.

Impact

  • 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.


Proof of Concept

Full Foundry test in test/audit/RegistryRewindPostTerminalBonusTheft.t.sol. Two test functions demonstrate both resolution paths:

function test_PostTerminalRewindFunnelsBonusToLateStaker_Moderator() public {
_runRewindThenStakePostTerminal();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
_assertBobCapturesMostBonus();
}
function test_PostTerminalRewindFunnelsBonusToLateStaker_Permissionless() public {
_runRewindThenStakePostTerminal();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
vm.prank(bob);
pool.claimExpired(); // auto-resolves PRODUCTION -> SURVIVED
_assertBobCapturesMostBonus();
}
function _runRewindThenStakePostTerminal() internal {
_stake(alice, 100 * ONE); // Alice: honest pre-risk staker
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // riskWindowStart latched
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow(); // riskWindowEnd = T latched
uint256 T = pool.riskWindowEnd();
_contributeBonus(carol, 100 * ONE); // bonus pool at stake
// BENIGN REGISTRY REWIND (mirrors testWithdrawRemainsDisabledAfterRegistryRewind):
MockAttackRegistry rewound = new MockAttackRegistry();
rewound.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
safeHarborRegistry.setAttackRegistry(address(rewound));
// riskWindowEnd stays pinned to T; riskWindowStart stays pinned (blocks withdraw per §9).
vm.warp(block.timestamp + 29 days); // 29 days AFTER T, 1 day before expiry
assertLt(block.timestamp, pool.expiry());
assertGt(block.timestamp, T); // Bob's entry is post-terminal
_stake(bob, 1 * ONE); // Bob: 1% of Alice's stake
}
function _assertBobCapturesMostBonus() internal {
uint256 aliceBefore = token.balanceOf(alice);
uint256 bobBefore = token.balanceOf(bob);
vm.prank(alice); pool.claimSurvived();
vm.prank(bob); pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore; // principal + 10.63% bonus
uint256 bobPayout = token.balanceOf(bob) - bobBefore; // principal + 89.37% bonus
assertGt(bobPayout, alicePayout);
assertGt(bobPayout - 1 * ONE, 89e18, "Bob's bonus capture >= 89% of bonus pool");
assertLt(alicePayout - 100 * ONE, 11e18, "Alice's bonus capture <= 11% of bonus pool");
}

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


Recommended Mitigation

The symmetric counterpart of the existing riskWindowStart-vs-withdraw latch (DESIGN §9 + the testWithdrawRemainsDisabledAfterRegistryRewind regression). Two minimal, behavioral-preserving fixes:

Option A — Ceiling stake()'s entry time against riskWindowEnd (preferred)

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
+ // Once terminal observation sealed `T`, no future stake may enter past it. A registry
+ // rewind re-opening NEW_DEPLOYMENT/UNDER_ATTACK cannot un-seal `riskWindowEnd`, and a
+ // post-terminal entrant's `newEntry > T` would otherwise inflate the k=2 score via
+ // `(T - newEntry)^2` (parabolic, unbounded). Ceiling here preserves the documented
+ // "T is the upper bound" invariant from DESIGN §7.
+ uint256 end = riskWindowEnd;
+ if (end != 0 && newEntry > end) newEntry = end;
...

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).

Option B — Block stake() outright once any terminal observation has latched

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ if (riskWindowEnd != 0) revert StakingClosed(); // terminal observation latches deposit lock
_assertDepositsAllowed(_observePoolState());
...

Strictly 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.

Optional — apply the symmetric ceiling in _clampUserSums for defense-in-depth

function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
if (userSumStakeTime[u] < stake_ * start) {
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start;
}
+ // Symmetric ceiling against riskWindowEnd: a rewind that re-enables stake must not let
+ // late-entrant sums survive with `newEntry > T`. (Defense-in-depth alongside the stake()
+ // ceiling; redundant once Option A is applied.)
+ uint256 end = riskWindowEnd;
+ if (end != 0 && userSumStakeTime[u] > stake_ * end) {
+ userSumStakeTime[u] = stake_ * end;
+ userSumStakeTimeSq[u] = stake_ * end * end;
+ }
}

This 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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!