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

Unrestricted bonus sweep before re-flag @ ConfidencePool.sweepUnclaimedBonus() leads to stolen whitehat bounty

Author Revealed upon completion

Unrestricted bonus sweep before re-flag @ ConfidencePool.sweepUnclaimedBonus() leads to stolen whitehat bounty

Description

  • Normal behavior: flagOutcome allows the moderator to re-flag an outcome once, pre-claim, so they can correct a mistaken call (e.g. an outcome that turns out to actually be an in-scope, good-faith CORRUPTED after first being flagged SURVIVED). When a good-faith CORRUPTED is (re-)flagged, bountyEntitlement is set to snapshotTotalStaked + snapshotTotalBonus — the entire pool, principal plus bonus — and the named attacker/whitehat is meant to be able to claim all of it via claimAttackerBounty(). This "full pool to the whitehat" guarantee is stated directly in the contract's own NatSpec.

  • The specific issue: snapshotTotalBonus is read from the live totalBonus variable at the moment flagOutcome runs — including on a re-flag. But sweepUnclaimedBonus() is deliberately not gated by claimsStarted (so that a 1-wei donation can't be used to grief the moderator's re-flag window), and it does permanently decrement totalBonus whenever the bonus looks "unreserved" (e.g. riskWindowStart == 0, the accepted "no observed risk" liveness gap from docs/DESIGN.md §5). This creates a window: after a provisional SURVIVED flag but before the moderator's correction to good-faith CORRUPTED, anyone can permissionlessly call sweepUnclaimedBonus(), sending the entire bonus to the sponsor-controlled recoveryAddress and zeroing totalBonus — before the re-flag ever reads it. The moderator's correction then computes bountyEntitlement from an already-drained totalBonus, silently shorting the rightful whitehat by the full bonus amount, which has already gone to the sponsor instead.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// ... outcome validation ...
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
// @> Reads totalBonus LIVE at (re-)flag time. If sweepUnclaimedBonus() already ran
// @> and zeroed it since the FIRST flag, this snapshot is silently short.
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
// @> "Full pool to the attacker" - but snapshotTotalBonus may already have been drained.
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
...
}
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) {
reserved += snapshotTotalBonus - claimedBonus;
}
// @> When riskWindowStart == 0, the bonus is never reserved - fully sweepable
// @> immediately after a provisional SURVIVED flag, before any claim happens.
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
// @> Permanently zeroes the live totalBonus that a LATER re-flag will read.
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// @> Intentionally does NOT set claimsStarted - so flagOutcome's re-flag guard
// @> (`outcome != UNRESOLVED && claimsStarted`) does not block the later correction,
// @> letting the re-flag proceed against already-corrupted bookkeeping.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

  • Occurs whenever a moderator's first flag (SURVIVED, chosen when the registry state is legitimately CORRUPTED but judged out-of-scope) is later corrected to good-faith CORRUPTED — a scenario the contract's own re-flag mechanism exists specifically to support, and one made more likely by the "no observed risk" liveness gap already accepted in docs/DESIGN.md §5.

  • sweepUnclaimedBonus() is fully permissionless and has no reason not to be called immediately after any SURVIVED/EXPIRED flag — it is the documented, intended way to clear dust/unreserved bonus, so a bot sweeping eagerly after every flag event will trigger this by default, with no adversarial coordination required.

  • The exploit window is exactly the moderator's own re-flag window — the same window docs/DESIGN.md explicitly says exists to let the moderator fix a mistaken call, meaning the bug is most likely to fire precisely when the protocol most needs the correction mechanism to work correctly.

Impact:

  • Directly contradicts the contract's own documented guarantee that a good-faith CORRUPTED entitles the named attacker/whitehat to the full pool (principal + bonus) — the whitehat instead recovers only the principal-equivalent portion.

  • The missing bonus is not lost to the system — it has already gone to recoveryAddress, which is sponsor-controlled — so the sponsor benefits directly at the expense of the party the protocol is designed to reward for legitimately reporting/rescuing an in-scope breach.

  • Undermines the whitehat incentive the entire CORRUPTED-good-faith mechanism exists to provide: a rational whitehat who anticipates this cannot trust that a moderator correction will actually make them whole, weakening the core security-rescue incentive of the protocol.

Proof of Concept

Create a Test file at test/unit/Audit.ReflagBonusDesyncTest.t.sol
and paste the below code

// 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";
contract AuditReflagBonusDesyncTest is BaseConfidencePoolTest {
function testSweepBeforeReflagStealsBonusFromGoodFaithAttacker() external {
address whitehat = makeAddr("whitehat");
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("bonusContributor"), 50 * ONE);
// Registry is observed directly in CORRUPTED - the accepted "liveness gap" from
// docs/DESIGN.md §5 (no active-risk state was ever polled by the pool).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator provisionally judges the breach out of scope and flags SURVIVED - an
// explicitly allowed branch (SURVIVED may be flagged when state == CORRUPTED).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
assertEq(pool.claimsStarted(), false);
// Before anyone claims, anyone permissionlessly sweeps the "unreserved" bonus.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
assertEq(pool.totalBonus(), 0); // live bonus bookkeeping now desynced
assertEq(pool.claimsStarted(), false); // re-flag window still open
// Moderator corrects the call: this was actually an in-scope, good-faith rescue.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// Entitlement should be the full pool (150 ONE) per the documented mechanic - it isn't.
assertEq(pool.bountyEntitlement(), 100 * ONE, "bounty entitlement is missing the bonus");
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 100 * ONE, "whitehat never saw any bonus share");
assertEq(token.balanceOf(recovery), recoveryBefore + 50 * ONE, "sponsor kept the bonus");
}
}

Run it:

forge test --match-contract AuditReflagBonusDesyncTest -vvvv

You'll see the trace confirm, step by step: totalBonus goes from 50e18 to 0 inside sweepUnclaimedBonus(); claimsStarted stays false across that call; the moderator's second flagOutcome succeeds (not blocked); bountyEntitlement() reads back exactly 100e18 instead of the expected 150e18; and the whitehat's final balance matches the short entitlement while recovery walks away with the 50e18 that should have been the whitehat's.

Recommended Mitigation

Two independent fixes, either of which closes the gap; using both is the more robust option since they patch it from different angles.

Fix 1 — freeze the bonus figure a re-flag can see, not just the outcome. Snapshot totalBonus (and totalEligibleStake) once, on the first flag, and reuse that frozen figure on any re-flag, the same way _firstGoodFaithCorruptedAt/corruptedClaimDeadline are already frozen on first entry and deliberately not refreshed:

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

This is the more surgical fix — it doesn't touch sweepUnclaimedBonus() at all, and it matches the pattern the contract already uses elsewhere (freeze-on-first-entry) instead of introducing a new one.

Fix 2 — don't let the "unreserved" bonus be swept while a re-flag is still possible. Block sweepUnclaimedBonus() during the same window flagOutcome's re-flag guard checks, i.e. require claimsStarted before any bonus can be swept away, closing the gap from the other side:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Don't let a permissionless sweep drain funds a still-open moderator re-flag might
+ // need to correctly compute a later good-faith CORRUPTED entitlement.
+ if (!claimsStarted) revert ReflagWindowStillOpen();
uint256 reserved;
...

Note this second fix reintroduces the exact griefing concern the original code's comment warns about (a 1-wei donation flipping claimsStarted to lock the re-flag window) unless it's paired with a change that makes sweepUnclaimedBonus() itself not toggle claimsStarted — which it already doesn't, so Fix 2 as written only blocks the sweep, it doesn't reintroduce the griefing vector. Fix 1 alone is sufficient and doesn't carry that trade-off, so it's the recommended primary fix.

Best regards,
Aliyu

Support

FAQs

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

Give us feedback!