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

SweepUnclaimedBonus_Drains_Attacker

Author Revealed upon completion
## Summary
A permissionless `sweepUnclaimedBonus()` between a moderator's `SURVIVED` flag and a corrective `CORRUPTED` re-flag irreversibly drains the bonus to `recoveryAddress`, permanently reducing the whitehat attacker's bounty. The protocol pays the wrong recipient with no recovery path.
## Description
`sweepUnclaimedBonus` does not set `claimsStarted` (L503), so the re-flag window stays open after the sweep. When `riskWindowStart == 0`, bonus is unreserved and fully sweepable (L483-487). On re-flag, the snapshot captures the now-zeroed `totalBonus` (L358), producing a reduced `bountyEntitlement`:
```solidity
// ConfidencePool.sol L357-362
snapshotTotalBonus = totalBonus; // captures zeroed value after sweep
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus // bonus portion is zero
: 0;
```
Because the snapshot occurs after the sweep, no later code path restores the swept bonus into `bountyEntitlement`.
**Reachability of `riskWindowStart == 0`:** The registry requires `UNDER_ATTACK` before `CORRUPTED` (`AttackRegistry.sol` L324). But the pool only seals `riskWindowStart` on a pool-touching transaction (L793). If `approveAttack()` and `markCorrupted()` execute with no pool interaction in between (e.g., same block), `riskWindowStart` stays zero.
## Vulnerability Details
**Attack path:**
1. Registry reaches `CORRUPTED` with no pool interaction during `UNDER_ATTACK``riskWindowStart` stays zero.
2. Moderator flags `SURVIVED`. No call to `stake()`, `contributeBonus()`, or `pokeRiskWindow()` occurred during the active-risk interval.
3. Anyone calls `sweepUnclaimedBonus()` — bonus drains to `recoveryAddress`, `totalBonus` zeroed.
4. `claimsStarted` remains false — moderator re-flags to good-faith `CORRUPTED`.
5. `bountyEntitlement = snapshotTotalStaked + 0`. Attacker permanently loses the bonus portion.
## Risk
**Impact: High** — The protocol permanently pays the wrong recipient. The bonus (up to 100% of `totalBonus`) is irreversibly sent to `recoveryAddress` instead of the named whitehat attacker. The loss is bounded only by the bonus pool size and is independent of the attacker's exploit performance.
**Likelihood: Medium** — Requires a rare but valid registry path where the pool does not observe the active-risk window before `CORRUPTED`, combined with a moderator flag correction. The sweep is permissionless and trivially automatable by a MEV bot.
## Impact
The whitehat attacker permanently loses their bonus entitlement. In the PoC, the attacker receives 150 tokens instead of the expected 200 — a 25% loss. The swept bonus sits irreversibly at `recoveryAddress`. No re-flag, no subsequent call, and no admin action can restore it into the attacker's `bountyEntitlement`.
## Proof of Concept
Drop into `test/unit/` and run: `forge test --match-test testSweepBetweenFlagsReducesAttackerBounty -vvvv`
The PoC uses `MockAttackRegistry` (same test double used by all 256 official tests) to stage the `CORRUPTED` state. In production this is reached via `approveAttack()``markCorrupted()` with no pool interaction in between.
```solidity
// 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 PoC_SweepDrainsBounty is BaseConfidencePoolTest {
function testSweepBetweenFlagsReducesAttackerBounty() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(recovery), 50 * ONE);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 150 * ONE); // should be 200
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 150 * ONE);
assertEq(token.balanceOf(recovery), 50 * ONE);
}
}
```
## Tools Used
Manual review, Foundry
## Recommended Mitigation
Gate `sweepUnclaimedBonus` on `claimsStarted`:
```diff
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ if (!claimsStarted) revert OutcomeNotEligibleForSweep();
```
Alternatively, freeze `totalBonus` at the first `flagOutcome` and never re-snapshot it on re-flag, decoupling the sweep's accounting from the re-flag's snapshot.

Support

FAQs

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

Give us feedback!