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

`_bonusShare()`'s global score has no explicit overflow bound, so a pathologically large aggregate stake can permanently revert every `claimSurvived()` call

Author Revealed upon completion

Root + Impact

Description

  • Normally, _bonusShare() computes each staker's k=2 score and the pool-wide global score using
    plain checked uint256 arithmetic, then hands the final ratio to Math.mulDiv for the
    multiply-then-divide step. Math.mulDiv is what protects that last step from overflowing on a
    large snapshotTotalBonus (per the code's own comment on that line), but it does not, and cannot,
    protect the score terms computed before it.

  • stake() only proves that a single deposit's own moments fit in uint256 at deposit time
    (received * newEntry * newEntry). It never proves anything about the aggregate
    T² · totalEligibleStake term _bonusShare later computes at claim time, where T is the pool's
    frozen terminal timestamp (potentially far later than, and unrelated in magnitude to, any single
    deposit's own entry time) and totalEligibleStake is the sum across every staker, not one. A
    single staker's sufficiently large balance can make that aggregate term overflow even though
    their own deposit-time computation never came close to overflowing.

// src/ConfidencePool.sol, stake(), only proves the SINGLE deposit's own terms fit
uint256 contribTimeSq = received * newEntry * newEntry; // (line 253)
...
sumStakeTimeSq += contribTimeSq;
@> // no bound is proven on the AGGREGATE totalEligibleStake or on T, which is unrelated to
@> // newEntry and can be far larger (T is capped at expiry, not at any deposit's own timestamp)
// src/ConfidencePool.sol, _bonusShare(), raw checked arithmetic, no overflow guard
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
...
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // (line 708)
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
...
// Math.mulDiv below only protects THIS division, `plus`/`minus` above already reverted
// on overflow if they didn't fit, regardless of what mulDiv can handle.
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Because claimSurvived() computes _bonusShare before transferring anything, an overflow here
reverts the whole claim transaction, for every staker, not just the one holding the outsized
balance, and there is no alternate payout path once the outcome is finalized (withdraw() is
already permanently closed by that point).

Risk

Likelihood:

  • Occurs only when a single staker's balance is large enough to push
    outcomeFlaggedAt² · totalEligibleStake past type(uint256).max. At outcomeFlaggedAt's
    absolute ceiling (type(uint32).max, the largest timestamp the pool's uint32 fields can ever
    hold), that requires an aggregate stake on the order of ~1.9 × 10⁵⁸ wei, roughly 1.9 × 10⁴⁰
    tokens at 18 decimals
    . No real ERC20's total supply reaches within ~25 orders of magnitude of
    that figure; the PoC below only reaches it because the test harness's mock token has unrestricted
    minting. Every pool's stakeToken is additionally owner-vetted through the factory's
    allowedStakeToken allowlist before it can ever be used, which is the same trust boundary the
    codebase already leans on for excluding non-standard token behavior (fee-on-transfer, rebasing).

  • This is a materially different likelihood profile than the registry-rewind finding (M-02) in this
    same pass: that one required only realistic stake amounts and an externally-plausible event
    (a registry migration the team's own design doc treats as expected). This one requires a single
    balance no legitimate token, at any decimals count, could ever produce.

Impact:

  • If the precondition were ever met, the impact is severe: every ordinary staker's claimSurvived()
    reverts permanently, with principal and bonus both stuck (a full claim-path denial of service),
    and the staker holding the oversized balance loses their own principal too, so even a
    deliberately malicious actor gains nothing from triggering it (pure griefing, not profitable
    theft).

  • Rated Low rather than Medium/High specifically because the triggering input is not achievable by
    any real, honestly-supplied ERC20 token; it would require either an already out-of-scope
    malicious stake token (a different, already-documented trust assumption, "Pools assume a
    standard ERC20") or a token supply that does not exist for any real fungible asset.

Proof of Concept

Checked into the repo as a real, runnable file at
test/unit/ConfidencePool.largeStakeOverflow.t.sol:

forge test --match-contract ConfidencePoolLargeStakeOverflowTest -vv
function testPoC_LargeEarlyStakeOverflowsSurvivorBonusAndBlocksClaims() external {
pool.setExpiry(uint256(type(uint32).max) - 1);
uint256 entry = block.timestamp;
// The minimum aggregate stake that makes T^2 * totalEligibleStake overflow uint256 at
// T's absolute ceiling (type(uint32).max). No real ERC20 total supply reaches this.
uint256 griefStake = type(uint256).max / (entry * entry) / 2;
_stake(dave, griefStake);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry() - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Bob is an ordinary staker with no unusual balance -- his claim reverts purely because
// dave's pathological stake overflows the shared global-score computation.
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
}
[PASS] testPoC_LargeEarlyStakeOverflowsSurvivorBonusAndBlocksClaims() (gas: 636749)
revert reason observed: panic: arithmetic underflow or overflow (0x11)

Confirmed the referenced-but-nonexistent-in-repo test path (test/unit/ConfidencePool.poc.t.sol,
cited by a prior draft of this report) does not exist; this PoC was written fresh against the
existing BaseConfidencePoolTest harness and verified to reproduce the described panic.

Recommended Mitigation

Bound the aggregate stake the pool will accept so the k=2 score terms are provably safe at the
formula's known worst case (T at its uint32 ceiling), rather than relying on individual
deposits happening to fit:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+
+ // Defense-in-depth: prove the aggregate stake stays within a bound that keeps every k=2
+ // score term safe even at T's absolute ceiling (type(uint32).max), independent of any
+ // single deposit's own entry time.
+ if (totalEligibleStake + received > _MAX_AGGREGATE_STAKE) revert AggregateStakeTooLarge();

Where _MAX_AGGREGATE_STAKE is derived once as type(uint256).max / (2 * uint256(type(uint32).max) ** 2)
(covering both the multiplication and the addition in plus/userPlus), or equivalently, evaluate
the score with an overflow-safe/wide-multiplication helper (e.g. computing T² · stake via
Math.mulDiv against a fixed denominator of 1) instead of raw uint256 multiplication, so the
formula degrades gracefully instead of reverting a way for a legitimate, if unusually large, staker
to become unable to claim.

Support

FAQs

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

Give us feedback!