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

sweepUnclaimedBonus moves the bonus without latching claimsStarted, so a later SURVIVED->CORRUPTED re-flag computes the bounty on an emptied snapshot and underpays the whitehat by the whole bonus

Author Revealed upon completion

Root + Impact

src/ConfidencePool.solsweepUnclaimedBonus moves real value (the bonus) out of the pool but intentionally does NOT latch claimsStarted (to avoid a 1-wei donation blocking the §4 re-flag window). The moderator's re-flag window therefore stays open AFTER the bonus has physically left the pool. A re-flag that changes the outcome then computes its distribution against snapshotTotalBonus, which the sweep has already zeroed — producing a payout short by the entire bonus.

When riskWindowStart == 0 (no observed risk — e.g. a direct-to-CORRUPTED or direct-to-PRODUCTION agreement), the bonus is unreserved and fully sweepable under a SURVIVED/EXPIRED flag. Sequence: moderator flags SURVIVED -> anyone sweeps the bonus to recoveryAddress -> moderator re-flags good-faith CORRUPTED (a legitimate §4 correction) -> the named whitehat's bountyEntitlement is computed as snapshotTotalStaked + snapshotTotalBonus, but snapshotTotalBonus is now 0. The whitehat receives only principal; the entire bonus is stuck at recoveryAddress.

Impact: the whitehat — who under good-faith CORRUPTED is entitled to the WHOLE pool — is underpaid by the full bonus, which is misdirected to the sponsor's recoveryAddress. Direct loss of funds for the legitimate CORRUPTED beneficiary; the §4 correction mechanism is not safe against an interleaved sweep.

Description

sweepUnclaimedBonus does not latch finality:

// 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);

@> amount is the FULL bonus when riskWindowStart == 0, not a 1-wei donation. It leaves the pool irreversibly, yet the re-flag window stays open. The same function also does totalBonus -= amount in this case, so the live totalBonus is now 0.

re-flag recomputes the snapshot from the emptied totalBonus:

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

@> bountyEntitlement is missing the bonus, so claimAttackerBounty pays the whitehat principal only.

Sequence:

  1. riskWindowStart == 0 (agreement reaches a terminal state without an observed risk window).

  2. Moderator flags SURVIVED (breach judged out-of-scope — §8 allows SURVIVED on a CORRUPTED registry).

  3. sweepUnclaimedBonus() (permissionless) sends the full bonus to recoveryAddress; does not latch claimsStarted.

  4. Moderator re-flags good-faith CORRUPTED (new info: the breach was in-scope). Allowed because claimsStarted == false (§4 window open).

  5. bountyEntitlement = snapshotTotalStaked + 0. Whitehat claims and receives principal only; bonus stuck at recoveryAddress.

Risk

Likelihood: Low — requires riskWindowStart == 0, a SURVIVED->CORRUPTED moderator correction, and a sweep interleaved between the two flags. The sweep is permissionless and a sponsor who controls recoveryAddress is incentivized to call it.

Impact: High — direct loss of funds: the whitehat is underpaid by the entire bonus, which is misdirected to the sponsor.

Proof of Concept

forge test --match-contract SweepThenReflagUnderpaysAttackerTest -vv passes against the in-scope contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract SweepThenReflagUnderpaysAttackerTest is BaseConfidencePoolTest {
function test_sweepBonusThenReflagCorrupted_underpaysWhitehat() external {
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("sponsor"), 500 * ONE);
// Agreement goes terminal CORRUPTED directly (no UNDER_ATTACK => riskWindowStart 0).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Moderator flags SURVIVED (breach judged out-of-scope).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Anyone sweeps the unreserved bonus to recoveryAddress. Does NOT latch claimsStarted.
pool.sweepUnclaimedBonus();
assertEq(pool.claimsStarted(), false, "re-flag window still open");
// Moderator corrects to good-faith CORRUPTED, naming the whitehat.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Entitlement should be 600 (stake + bonus); it is only 100.
assertEq(pool.bountyEntitlement(), 100 * ONE, "entitlement missing the 500 bonus");
uint256 before = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - before, 100 * ONE, "whitehat underpaid: bonus lost to recovery");
}
}

Recommended Mitigation

Any of: (a) latch claimsStarted = true in sweepUnclaimedBonus when it moves non-dust value — distinguish a real bonus sweep from a 1-wei donation-dust sweep and only skip the latch for dust — closing the re-flag window once real value has left; (b) block sweepUnclaimedBonus while the outcome is still re-flaggable (claimsStarted == false), reserving the bonus until the outcome is final; (c) on re-flag, derive the new snapshotTotalBonus from accounting that includes already-swept amounts, so a prior sweep cannot silently shrink the new outcome's entitlement.

Support

FAQs

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

Give us feedback!