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

[H-1] Permissionless `sweepUnclaimedBonus()` Between a `SURVIVED` Flag and a Corrective Good-Faith `CORRUPTED` Re-flag Permanently Misroutes the Whitehat's Bounty

Author Revealed upon completion

Description:

flagOutcome permits a moderator to re-flag the pool's outcome pre-claim so an incorrect initial judgement can be corrected (ConfidencePool.sol:327):

if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

This is deliberate: a moderator may initially flag SURVIVED on a registry that reads CORRUPTED (permitted — SURVIVED accepts either PRODUCTION or CORRUPTED, ConfidencePool.sol:338, for the case where the breach is judged out of the pool's committed scope), then correct to good-faith CORRUPTED if further investigation shows the breach was in-scope after all. The correction is only blocked once claimsStarted is true.

Separately, sweepUnclaimedBonus is fully permissionless and, when riskWindowStart == 0, does not reserve the bonus and does not set claimsStarted (ConfidencePool.sol:483-490, 503-507):

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
...
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount);

The does NOT set claimsStarted behavior is intentional and documented — it exists so a trivial 1-wei donation sweep can't accidentally block the moderator's correction window. But nothing distinguishes that intended dust case from a call that sweeps a real, material bonus. Anyone (including the sponsor, who directly controls recoveryAddress and therefore has a direct incentive) can call sweepUnclaimedBonus() in the gap between the initial SURVIVED flag and the moderator's correction. This drains the entire bonus to recoveryAddress and zeroes totalBonus — all while claimsStarted stays false, so the correction is still permitted.

When the moderator then corrects to good-faith CORRUPTED, the re-snapshot re-reads the now-drained value (ConfidencePool.sol:358, 362):

snapshotTotalBonus = totalBonus; // already zeroed by the sweep
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

Per the project's own documentation (docs/DESIGN.md §12): "the entire pool is the named attacker's bounty." Because of the sweep, the whitehat's bountyEntitlement is computed short by exactly the bonus amount — which is now permanently sitting at recoveryAddress instead.

Impact:

A named good-faith whitehat attacker — the party the protocol is explicitly designed to reward for reporting an in-scope breach — is permanently and silently shorted the entire bonus portion of the pool. The funds are not recoverable by the attacker afterward; they sit at recoveryAddress, which is sponsor-controlled. Since the sponsor is the direct economic beneficiary of recoveryAddress, the sponsor has a standing incentive to trigger this themselves (or via any address) the moment a SURVIVED flag lands on a CORRUPTED-reading registry, before the moderator corrects it — extracting value that documentation guarantees belongs to the whitehat.

Proof of Concept:

The following Foundry test, run against the actual contract, reproduces the issue end-to-end. Both assertions pass on unmodified code.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {console2} from "forge-std/console2.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract SweepBeforeReflagBountyTest is BaseConfidencePoolTest {
function testSweepUnclaimedBonusBeforeReflagStealsWhitehatBounty() external {
uint256 stakeAmt = 100 * ONE;
uint256 bonusAmt = 40 * ONE;
_stake(alice, stakeAmt);
_contributeBonus(carol, bonusAmt);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "precondition: risk window never observed");
// Moderator's initial judgement: looks out-of-scope, flags SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
// ATTACK: anyone sweeps the "unclaimed" bonus before the correction lands.
address griefer = makeAddr("griefer");
vm.prank(griefer);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), bonusAmt, "bonus already swept to recoveryAddress");
assertEq(pool.totalBonus(), 0, "totalBonus drained before the corrective re-flag");
assertFalse(pool.claimsStarted(), "sweep deliberately does not latch finality");
// Moderator corrects to good-faith CORRUPTED -- succeeds, claimsStarted still false.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 expectedFullBounty = stakeAmt + bonusAmt; // 140e18 per DESIGN.md §12
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 attackerPayout = token.balanceOf(attacker);
// BUG: whitehat gets only the stake, not stake + bonus.
assertEq(attackerPayout, stakeAmt, "BUG: whitehat shorted the bonus entirely");
assertLt(attackerPayout, expectedFullBounty, "BUG: payout is less than the documented full-pool bounty");
assertEq(token.balanceOf(recovery), bonusAmt, "BUG: bonus misrouted to sponsor's recoveryAddress");
}
}

Executed trace confirms the exact numbers:

sweepUnclaimedBonus() -> MockERC20::transfer(recovery, 40e18) // bonus swept
totalBonus() -> 0 // drained
flagOutcome(CORRUPTED, true, attacker) succeeds (claimsStarted == false)
bountyEntitlement() -> 100e18 // should be 140e18
claimAttackerBounty() -> attacker receives 100e18
balanceOf(recovery) -> 40e18 // stranded, never returned

Suite result: 2 passed; 0 failed (includes a control test confirming the same scenario without the sweep correctly pays the attacker the full 140e18, isolating the sweep as the root cause).

Recommended Mitigation:

Prevent the bonus from leaving the pool while the outcome can still be corrected. The minimal fix is to gate the sweep on claimsStarted:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // The bonus may only leave the pool once the outcome can no longer be re-flagged --
+ // otherwise a SURVIVED -> good-faith-CORRUPTED correction recomputes the whitehat's
+ // bounty from a drained totalBonus and misroutes the bonus to recoveryAddress.
+ if (!claimsStarted) revert OutcomeNotFinal();

This closes the window without reintroducing the 1-wei-donation griefing concern the current comment describes, since claimsStarted is already set by the first genuine claim on any resolved outcome — a real claimant acting normally, not a donation, is what unlocks the sweep.

An alternative that preserves the current "sweep doesn't need a claim first" behavior: set a one-way bonusSwept latch inside sweepUnclaimedBonus, and have flagOutcome revert a re-flag into good-faith CORRUPTED if that latch is set — forcing an explicit, visible failure instead of a silent under-payment.

Support

FAQs

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

Give us feedback!