FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Missing riskWindowEnd rewind-latch in _assertDepositsAllowed lets late stakes/bonus theft via k=2 quadratic scoring

Impact - Medium Bonus pool only, principal always returns under SURVIVED/EXPIRED, but the quadratic advantage is unbounded and a tiny late stake can take nearly all of it. Likelihood - Low It needs a DAO registry migration to land inside the window where a pool is unresolved with riskWindowEnd already sealed, and the attacker still has to be watching. No attacker action can create the precondition.

Support

FAQs

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

Give us feedback!