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

sweepUnclaimedBonus can be used to drain the bonus before a CORRUPTED re-flag, cheating the whitehat out of their bounty

Author Revealed upon completion

Root + Impact

Description

sweepUnclaimedBonus intentionally skips claimsStarted to keep the moderator's re-flag window
open. When riskWindowStart == 0 it also decrements totalBonus to zero. On a subsequent
re-flag to good-faith CORRUPTED, flagOutcome re-snapshots snapshotTotalBonus = totalBonus,
reading zero. bountyEntitlement becomes snapshotTotalStaked + 0 instead of
snapshotTotalStaked + B, permanently stranding bonus B at recoveryAddress and contradicting
DESIGN.md §12's guarantee that the entire pool is the attacker's bounty.

// ConfidencePool.sol::sweepUnclaimedBonus
// @> totalBonus zeroed out here while claimsStarted stays false
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // @> totalBonus = 0
}
// @> claimsStarted intentionally NOT set, re-flag window remains open
// ConfidencePool.sol::flagOutcome (on re-flag)
// @> re-snapshots live totalBonus, which is now 0 after the sweep
snapshotTotalBonus = totalBonus; // @> reads 0 instead of B
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus // @> S + 0 instead of S + B
: 0;

Risk

Likelihood:

  • riskWindowStart stays zero if no pool interaction occurred during UNDER_ATTACK; pokeRiskWindow cannot retroactively seal it because _isActiveRiskState(CORRUPTED) is false.

  • Moderator incorrectly flagging SURVIVED on a CORRUPTED registry is the exact mistake DESIGN.md §4 acknowledges the re-flag window exists to correct.

  • sweepUnclaimedBonus is permissionless; the sponsor controls recoveryAddress and receives B directly, giving them financial incentive to call it the moment the wrong flag lands.

Impact:

  • The named whitehat receives only snapshotTotalStaked instead of the full snapshotTotalStaked + snapshotTotalBonus. Bonus B is permanently transferred to the sponsor's recoveryAddress with no recovery path.

Proof of Concept

Alice and Bob stake 150 total; Carol contributes 40 bonus. Registry reaches CORRUPTED with
riskWindowStart == 0. Moderator flags SURVIVED; sponsor sweeps the 40-token bonus to
recoveryAddress, zeroing totalBonus. Moderator corrects to good-faith CORRUPTED but the
re-snapshot reads totalBonus = 0, so bountyEntitlement is 150 instead of 190. Attacker
receives 150; sponsor keeps 40.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
// forge test --match-test testPoc_SweepBonusBeforeReflagShortchangesAttacker -vvvv
contract SweepBonusCorruptedReflagPoC is BaseConfidencePoolTest {
function testPoc_SweepBonusBeforeReflagShortchangesAttacker() external {
uint256 S = 150 * ONE;
uint256 B = 40 * ONE;
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, B);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), B);
assertEq(pool.claimsStarted(), false);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, B);
assertEq(pool.totalBonus(), 0);
assertEq(pool.claimsStarted(), false);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0); // should be B
assertEq(pool.bountyEntitlement(), S); // should be S + B
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), S); // missing B
assertEq(token.balanceOf(recovery) - recoveryBefore, B); // sponsor kept B
assertEq(token.balanceOf(address(pool)), 0);
}
}

Recommended Mitigation

On a re-flag, keep the existing snapshotTotalBonus instead of re-reading totalBonus.
Stakes and sums can legitimately change between flags, but totalBonus can only be depleted
by a sweep that bypasses claimsStarted, so it must not be re-read on correction.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
+ bool isReflag = outcome != PoolStates.Outcome.UNRESOLVED;
IAttackRegistry.ContractState state = _observePoolState();
// ... validation ...
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
- snapshotTotalBonus = totalBonus;
+ snapshotTotalBonus = isReflag ? snapshotTotalBonus : totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;

Support

FAQs

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

Give us feedback!