FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!