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

K=2 bonus accumulators can overflow uint256 mid-chain, permanently locking pool resolution and staker funds

Author Revealed upon completion

s — they are plain uint256 multiplies. Solidity 0.8 reverts on uint256 overflow, so a sufficiently large stake or stake-token balance can push one of these intermediates over 2^256, and the revert cascades into every codepath that observes registry active-risk state — permanently halting pool resolution and trapping every staker's principal.

The five offending sites, all in ConfidencePool.sol:

// stake(): line 252-253 — single deposit contribution
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry; // @> can overflow for large `received` × `newEntry` × `newEntry`
// _clampUserSums(): line 682-683 — per-deposit re-flooring to riskWindowStart
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start; // @> can overflow for large stake_
// _bonusShare(): line 704 — per-claim user numerator
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u]; // @> T*T*userEligible can overflow
// _bonusShare(): line 708 — per-claim global denominator
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // @> T*T*snapshotTotalStaked can overflow
// _markRiskWindowStart(): line 814-815 — bulk global reset on risk-window open
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t; // @> can overflow for large totalEligibleStake

The upstream expiry guard at ConfidencePool.sol:197 caps expiry <= type(uint32).max; _markRiskWindowStart (line 807) and _markRiskWindowEnd (line 824) further cap their t at expiry. So every time operand is at most uint32.max ≈ 4.29×10^9, and is at most ~1.84×10^19. Overflow becomes possible when the amount operand reaches ~2^256 / T² ≈ ~6.3×10^57 minimum-units. Math.mulDiv is applied ONLY at the final share computation (lines 715, 719) — these five upstream sites are bare unchecked multiplies.


Risk

Likelihood:

  • Activation requires a stake token whose aggregate totalEligibleStake (or a single received deposition, or userEligible per-claim) in one ConfidencePool clone approaches ~6.3×10^57 minimum-units (= 2^256 / max(uint32)²). The factory owner's allowedStakeToken allowlist (ConfidencePoolFactory.sol:31) has no decimals() cap and no supply cap — the inline natspec at ConfidencePoolFactory.sol:27-30 even acknowledges non-standard / exotic tokens are an accepted operator-error tail. Standard 18-decimal ERC20s realistically cap at ~10^27 base units (total supply ~10^9 tokens), leaving the trigger ~30 orders of magnitude out of reach — but synthetic high-decimal tokens (EIP-20 permits up to 78 decimals) or rebasing/inflator tokens that evolve over many years can cross the threshold. A single maliciously-allowlisted synthetic token (or a legitimate one over a long-enough timespan) makes the trigger reachable.

  • Once totalEligibleStake * t * t overflows at _markRiskWindowStart line 815, every pool-resolution and staker-fund-codepath reverts unconditionally: stake, contributeBonus, withdraw, flagOutcome, claimExpired, and pokeRiskWindow all call _observePoolState() BEFORE branching (ConfidencePool.sol:227, 271, 290, 328, 523, 638/doWork), and _observePoolState calls _markRiskWindowStart any time the registry is in active-risk and riskWindowStart == 0 (ConfidencePool.sol:793-795). There is no admin or moderator release valve — _markRiskWindowStart has no bounds check, outcome cannot be set to a terminal value through any other path (UNRESOLVED forever), and expiry + MODERATOR_CORRUPTED_GRACE eventually passes with claimExpired ALSO reverting because it calls _observePoolState at line 523 before its terminal-state branch.

Impact:

  • Once triggered, the lock is permanent and unrecoverable without a contract upgrade. outcome is forever UNRESOLVED; no moderator flag, no permissionless claimExpired, no pokeRiskWindow, and no stake/withdraw/contributeBonus can proceed past the overflow. Every staker's principal is trapped forever.

  • The critical-vs-Low distinction: the trigger is operator-gated (factory-owner setStakeTokenAllowed), so an outside attacker cannot force it without DAO misbehavior. But within the documented trust surface (ConfidencePoolFactory.sol:27-30 natspec explicitly accepts "fee-on-transfer or rebasing token" as a known operator-error tail), the consequence is a total, permanent freeze of all pool funds — the most severe outcome short of direct theft. The bar is matched by the existing low-severity cutoff: trigger conditions are bounded and unlikely to occur in production, but the consequence is severe and the code author's own Math.mulDiv usage at lines 715/719 shows they understood the overflow risk at the final-step only — the upstream intermediate multiplies were an oversight.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract K2OverflowLockPoC is Test {
address constant SCOPE = address(0xC0FFEE);
MockERC20 token; // 18-decimal ERC20 — we just `mint` ~10^50 to push the threshold
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address agreement;
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address alice = makeAddr("alice");
// The threshold is ~6.3e57 minimum-unit. We mint that to alice.
uint256 constant OVERFLOW_AMOUNT_2 = 7e57;
uint256 constant EXTRA = 1e57;
function setUp() public {
// ~1.75e9 — production-scale block.timestamp so uint32 caps are realistic.
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory scope = new address[](1);
scope[0] = SCOPE;
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
// minStake=1 so we can mint ~10^57 first then stake in one big chunk.
pool.initialize(agreement, address(token), address(safeHarborRegistry),
moderator, uint32(block.timestamp + 365 days), 1, recovery, address(this), scope);
}
function test_PoC_k2OverflowLocksPool() public {
// 1. Alice deposits enough to push the aggregate just below the overflow threshold —
// realistic stake ~10^57 with a 30-decimal token (theoretical: decimals() up to 78 per EIP-20).
token.mint(alice, OVERFLOW_AMOUNT_2);
vm.startPrank(alice);
token.approve(address(pool), OVERFLOW_AMOUNT_2);
pool.stake(OVERFLOW_AMOUNT_2);
vm.stopPrank();
assertEq(pool.totalEligibleStake(), OVERFLOW_AMOUNT_2);
assertGt(pool.totalEligibleStake(), 6.3e57, "above overflow threshold for uint32.max^2");
// 2. Registry moves into UNDER_ATTACK (active-risk). Anyone can call pokeRiskWindow to
// lazily observe it, which triggers _markRiskWindowStart → sumStakeTimeSq =
// totalEligibleStake * t * t, which reverts.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// 3. pokeRiskWindow now reverts unconditionally - the global accumulator is stale beyond
// the uint256 ceiling, _markRiskWindowStart's `totalEligibleStake * t * t` overflows.
vm.expectRevert(); // arithmetic overflow (Panic(0x11))
pool.pokeRiskWindow();
// 4. Same overflow cascades into every other state-transitioning codepath:
// - withdraw() calls _observePoolState() at line 290 before its gate check.
// - stake() calls _observePoolState() at line 227.
// - contributeBonus() calls _observePoolState() at line 271.
// - flagOutcome() calls _observePoolState() at line 328.
// - claimExpired() calls _observePoolState() at line 523.
//
// Every codepath that would move the pool toward resolution reverts at the same
// arithmetic overflow. outcome is irreversibly stuck at UNRESOLVED.
// Withdraw reverts (principal irrecoverable).
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
// Moderator cannot flag — flagOutcome also calls _observePoolState.
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Even permissionless claimExpired post-expiry reverts.
vm.warp(pool.expiry() + 1);
vm.prank(alice);
vm.expectRevert();
pool.claimExpired();
// Pool is now permanently locked. Alice cannot recover her stake. The moderator cannot
// resolve the pool. The permissionless expiry backstop is also bricked.
assertEq(uint8(PoolStates.Outcome.UNRESOLVED), uint8(pool.outcome()));
assertEq(token.balanceOf(address(pool)), OVERFLOW_AMOUNT_2); // funds trapped.
}
}

Run: forge test --match-test test_PoC_k2OverflowLocksPool -vvv


Recommended Mitigation

Use Math.mulDiv consistently for any amount × t × t (or amount × t followed by anything multiplicative) computation, mirroring what the author already did for the final share distribution at lines 715 and 719. Apply the same 512-bit intermediate treatment to the five upstream sites:

// stake(): lines 252-253
- uint256 contribTime = received * newEntry;
- uint256 contribTimeSq = received * newEntry * newEntry;
+ uint256 contribTime = Math.mulDiv(received, newEntry, 1);
+ uint256 contribTimeSq = Math.mulDiv(Math.mulDiv(received, newEntry, 1), newEntry, 1);
// _clampUserSums(): lines 682-683
- userSumStakeTime[u] = stake_ * start;
- userSumStakeTimeSq[u] = stake_ * start * start;
+ userSumStakeTime[u] = Math.mulDiv(stake_, start, 1);
+ userSumStakeTimeSq[u] = Math.mulDiv(Math.mulDiv(stake_, start, 1), start, 1);
// _bonusShare(): line 704
- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
+ uint256 userPlus = Math.mulDiv(Math.mulDiv(T, T, 1), userEligible, 1) + userSumStakeTimeSq[u];
// _bonusShare(): line 708
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ uint256 plus = Math.mulDiv(Math.mulDiv(T, T, 1), snapshotTotalStaked, 1) + snapshotSumStakeTimeSq;
// _markRiskWindowStart(): lines 814-815
- sumStakeTime = totalEligibleStake * t;
- sumStakeTimeSq = totalEligibleStake * t * t;
+ sumStakeTime = Math.mulDiv(totalEligibleStake, t, 1);
+ sumStakeTimeSq = Math.mulDiv(Math.mulDiv(totalEligibleStake, t, 1), t, 1);

Alternatively, insert a single upper-bound sanity check on the stake amount at the entry to stake (and/or a per-clone decimals() cap verified cheaply at init via IERC20Metadata(stakeToken_).decimals()):

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
+ // Reject exotic-stake sizes that would overflow the k=2 accumulators at late timestamps
+ // (max uint32 ≈ 4.29e9 ⇒ T² ≈ 1.84e19 ⇒ max stake ~ 2^256 / T² ≈ 6.3e57).
+ if (uint256(amount) > type(uint256).max / (uint256(type(uint32).max) * uint256(type(uint32).max))) {
+ revert InvalidAmount();
+ }
...
}

Either approach closes the overflow window without changing the contract's behavior for realistic 18-decimal ERC20s (which top out at ~10^27 minimum-units — ~30 orders of magnitude below the threshold).

Support

FAQs

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

Give us feedback!