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

Overflow in k=2 score calculation can permanently freeze SURVIVED and EXPIRED claims

Author Revealed upon completion

Root + Impact

Description

Summary

The k=2 bonus accounting expands amount * (T - entryTime)^2 using absolute Unix timestamps. Although each stake-time accumulator can fit within uint256, the intermediate additions in _bonusShare() can overflow during claim settlement.

A malicious staker can use a sufficiently high-unit standard ERC20 deposit to make every claimSurvived() and claimExpired() call revert, permanently locking both principal and bonus funds.

Finding Description

_bonusShare() computes the user and global scores as:

uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;

The Math.mulDiv() call only protects the final userScore * snapshotTotalBonus / globalScore operation. It does not protect the preceding multiplication and addition operations.

The issue is caused by catastrophic intermediate growth from absolute timestamps. A stake can be accepted because amount * entryTime^2 fits in uint256, while the later expression T^2 * totalStake + sum(amount * entryTime^2) exceeds uint256, even though the intended mathematical score, amount * (T - entryTime)^2, is small.

Likelihood:

The attack requires a token with sufficiently large raw-unit supply, but the factory supports arbitrary standard ERC20 tokens and does not impose a maximum token decimal count, supply, stake amount, or aggregate stake amount. A high-decimal standard ERC20 can make the required raw amount economically feasible.

No privileged role is required after such a token has been allowlisted: an attacker only needs to stake before resolution and can then cause claims to fail for all stakers.

Impact:

Once a pool resolves as SURVIVED or EXPIRED, every staker claim calls _bonusShare() and reverts with Panic(0x11). Since sweepUnclaimedBonus() must reserve unclaimed principal and bonus, it cannot recover the locked funds either.

This permanently freezes all outstanding staker principal and bonus in the affected pool. A corruption outcome is not a recovery mechanism because it redirects the pool to the recovery address or whitehat rather than returning principal to stakers.

Proof of Concept

Create test/unit/ConfidencePool.overflow.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolOverflowTest is BaseConfidencePoolTest {
// Computed for:
// riskWindowStart = 1_750_000_000
// T = 1_750_864_000
//
// Each stored stake-time term fits in uint256, but:
// T^2 * totalStake + snapshotSumStakeTimeSq > type(uint256).max
uint256 internal constant ATTACKER_STAKE =
28_329_266_311_502_751_926_300_344_339_504_137_898_601_206_105_391_597_057_856;
function testLargeStakeFreezesAllSurvivedClaims() external {
// Honest staker deposits before the risk window opens.
_stake(alice, ONE);
// Open the risk window at BaseConfidencePoolTest.BASE_TIMESTAMP.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// Malicious staker deposits an amount that keeps storage writes in range.
_stake(bob, ATTACKER_STAKE);
// A non-zero bonus ensures _bonusShare() executes during claims.
_contributeBonus(carol, ONE);
// Set T = 1_750_864_000, still before the pool expiry.
vm.warp(vm.getBlockTimestamp() + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Alice's own score terms fit, but the global `plus` calculation overflows.
bytes4 panicSelector = bytes4(keccak256("Panic(uint256)"));
vm.expectRevert(abi.encodeWithSelector(panicSelector, uint256(0x11)));
vm.prank(alice);
pool.claimSurvived();
}
}

The test reverts at the global score calculation:

uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;

Both terms fit independently, but their sum exceeds type(uint256).max.

Recommended Mitigation

Avoid expanding the quadratic formula with absolute Unix timestamps. Store and calculate time offsets relative to riskWindowStart, and use overflow-safe full-precision arithmetic for all intermediate terms.

Additionally, enforce an explicit aggregate-stake bound before accepting a deposit. The bound must guarantee that the largest possible values of T^2 * totalEligibleStake, snapshotSumStakeTimeSq, and 2 * T * snapshotSumStakeTime remain within the arithmetic domain used by the implementation.

Support

FAQs

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

Give us feedback!