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

Open re-flag window still allows full bonus sweep, permanently stripping a later good-faith CORRUPTED bounty

Author Revealed upon completion

Description

Independent triage conclusion: valid issue.

In no-risk-window corrupted scenarios, sweepUnclaimedBonus() can transfer the entire accounted bonus to recoveryAddress while the documented pre-claim re-flag window is still open. If the moderator then corrects the outcome from SURVIVED to good-faith CORRUPTED, the new bounty snapshot is taken from the reduced live accounting, so the named attacker permanently loses the swept bonus.

This is a distinct root cause from the previously retained scope-lock finding. It is also distinct from the accepted claimExpired no-risk-window race and from the accepted "re-flag closes on first claim" model, because here real value movement happens without closing the correction window.

Affected code

Risk

The contract allows pre-claim re-flagging so the moderator can correct an initially wrong outcome or attacker address before anyone locks in the wrong distribution.

However, sweepUnclaimedBonus() intentionally does not set claimsStarted. In normal donation-only cases that makes sense: a trivial post-resolution donation should not let an outsider close the moderator's correction window.

The problem is that the same function also handles the riskWindowStart == 0 branch, where the entire accounted bonus is treated as unreserved and is transferred to recoveryAddress:

  1. The registry is CORRUPTED, but no active-risk state was ever observed, so riskWindowStart == 0.

  2. The moderator initially flags SURVIVED, leaving the correction window open because claimsStarted == false.

  3. Anyone calls sweepUnclaimedBonus(). Because no staker is owed bonus in the riskWindowStart == 0 branch, the function transfers the full bonus out of the pool and also decrements live totalBonus.

  4. claimsStarted still remains false, so the moderator is allowed to re-flag to good-faith CORRUPTED.

  5. On re-flag, flagOutcome() takes a fresh snapshot from the now-reduced live accounting.

  6. The named attacker can only claim principal, not principal plus bonus. The swept bonus is gone permanently.

So the protocol simultaneously claims:

  • the moderator can still correct the outcome before value movement locks the wrong distribution, and

  • the no-risk-window CORRUPTED path may still send the whole pool, bonus included, to the moderator-named attacker.

But sweepUnclaimedBonus() breaks both guarantees by moving real value without closing the correction window.

The existing unit test around this path proves solvency only: it checks that the later corrupted path does not brick and that entitlement shrinks to the remaining live balance. That does not make the economic loss acceptable.

Preconditions

  • The registry is CORRUPTED.

  • No active-risk state was ever observed by the pool, so riskWindowStart == 0.

  • The moderator first flags SURVIVED.

  • Before the moderator corrects the outcome, someone calls sweepUnclaimedBonus().

  • The moderator then re-flags to good-faith CORRUPTED.

Impact

The full bonus pool can be irreversibly redirected to the sponsor-controlled recoveryAddress even though the later corrected good-faith corrupted outcome should have awarded the entire pool to the named attacker.

This is direct fund loss with constraints:

  • the amount is unbounded because totalBonus is unbounded,

  • the transfer is irreversible,

  • the later corrected bounty cannot restore the lost bonus.

Baseline severity assessment: High.

Exploit cost/attack complexity

Exploit cost is low.

Attack complexity is low to medium:

  • the sweep is permissionless and cheap,

  • the beneficiary is deterministic because recoveryAddress is already set,

  • the main constraints are state/timing constraints: a no-risk-window corrupted path and an open correction window after an initial SURVIVED flag.

PoC Source

Scenario:

  1. A staker deposits and a bonus contributor funds the bonus pool.

  2. The registry moves directly to CORRUPTED without any observed active-risk state, so riskWindowStart == 0.

  3. The moderator initially flags SURVIVED, leaving claimsStarted == false.

  4. sweepUnclaimedBonus() transfers the full bonus to recoveryAddress but still leaves the re-flag window open.

  5. The moderator corrects the outcome to good-faith CORRUPTED.

  6. The attacker can only claim principal because the bounty snapshot was taken after the bonus had already been swept away.

How to run:

forge test --match-path test/audit/ConfidencePool.bonusSweepReflag.t.sol -vv

Full source code PoC:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/*
PoC title:
- Bonus sweep can irreversibly strip a later good-faith CORRUPTED bounty during the re-flag window
Target:
- src/ConfidencePool.sol
How to run:
- forge test --match-path test/audit/ConfidencePool.bonusSweepReflag.t.sol -vv
Expected output:
- [PASS] testExploit_bonusSweepStealsFutureGoodFaithBounty()
*/
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ConfidencePoolBonusSweepReflagPoCTest is BaseConfidencePoolTest {
function testExploit_bonusSweepStealsFutureGoodFaithBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no active-risk observation");
assertFalse(pool.claimsStarted(), "re-flag window still open");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus stolen during re-flag window");
assertFalse(pool.claimsStarted(), "value moved but re-flag window still open");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, 100 * ONE, "attacker lost the swept bonus");
assertEq(pool.bountyEntitlement(), 100 * ONE, "snapshot shrank after sweep");
}
}

PoC Results

Last validated result:

Ran 1 test for test/audit/ConfidencePool.bonusSweepReflag.t.sol:ConfidencePoolBonusSweepReflagPoCTest
[PASS] testExploit_bonusSweepStealsFutureGoodFaithBounty() (gas: 590625)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.99ms (569.84us CPU time)
Ran 1 test suite in 27.05ms (2.99ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Suggested Fix

Do not allow sweepUnclaimedBonus() to move accounted bonus while the moderator's re-flag window is still open in scenarios where a later corrected good-faith CORRUPTED outcome would need that value.

Practical fixes include:

  1. Treat any real value-moving bonus sweep as finality and set claimsStarted = true, but only after separating pure-donation sweeps from accounted-bonus sweeps.

  2. Alternatively, forbid sweepUnclaimedBonus() from sweeping accounted bonus when outcome == SURVIVED, claimsStarted == false, and a later moderator correction could still reclassify the pool into CORRUPTED.

  3. Another safe option is to preserve a separate immutable resolution snapshot for the open re-flag window so that later good-faith CORRUPTED re-flags cannot be based on already-reduced live accounting.

Mitigation

One practical mitigation is to distinguish between:

  • pure donation sweeps that should not close the moderator's correction window, and

  • sweeps that move accounted bonus which could still be needed by a later corrected CORRUPTED outcome.

If the sweep is consuming accounted bonus rather than unrelated donated balance, it should either:

  • close the re-flag window by setting claimsStarted = true, or

  • revert while the correction window is still open.

A minimal mitigation pattern is:

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;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
bool consumesAccountedBonus = (totalEligibleStake == 0 || riskWindowStart == 0) && amount <= totalBonus;
// If the open re-flag window could still change the pool into CORRUPTED,
// do not let accounted bonus leave without finalizing the outcome.
if (consumesAccountedBonus && outcome == PoolStates.Outcome.SURVIVED && !claimsStarted) {
revert OutcomeAlreadySet();
}
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

The exact final patch can differ, but the fix must guarantee that a later valid good-faith CORRUPTED re-flag cannot be forced to snapshot from already-reduced live accounting after real bonus value has been swept away.

Support

FAQs

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

Give us feedback!