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

A mistakenly named attacker can claim the entire pool before the moderator corrects the recipient

Author Revealed upon completion

Root + Impact

Description

The protocol permits the moderator to re-flag an outcome before the first claim. The docs explicitly describe this as a mechanism for correcting mistakes:

flagOutcome may be re-flagged pre-claim so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution.”

The design further states:

“A claim is the distribution-locking event. Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting.”

This is implemented by allowing flagOutcome() to overwrite an existing outcome while claimsStarted remains false:

function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith_,
address attacker_
) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd
// outcome / attacker before any participant locks in the wrong distribution.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
// ...
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
}

However, claims are enabled immediately after the outcome is flagged. There is no protected period in which the moderator can exercise the documented correction mechanism.

Consider an incident involving multiple attackers, A and B. The moderator decides that attacker A should receive the good-faith bounty but accidentally submits attacker B’s address.

Because attacker B controls the selected address, B can immediately call claimAttackerBounty():

function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
uint256 newBountyClaimed = bountyClaimed + payout;
bountyClaimed = newBountyClaimed;
if (payout > 0) {
corruptedReserve -= payout;
claimsStarted = true; // @> Permanently closes correction
stakeToken.safeTransfer(attacker, payout); // @> Entire pool goes to B
}
}

For a good-faith CORRUPTED outcome, the bounty consists of the complete snapshot of staked principal and contributed bonus. Attacker B can therefore receive the entire pool in one transaction.

Once the claim succeeds, claimsStarted becomes true. The moderator’s attempt to replace attacker B with the intended attacker A then reverts with OutcomeAlreadySet.

The mechanism assumes that the first claimant is exercising a correct outcome. That assumption is invalid when the correction feature is needed: the incorrectly advantaged attacker has a direct incentive to claim immediately and eliminate the correction opportunity.

Risk

Likelihood:

The moderator must select an incorrect but controlled address, such as another participant in a multi-attacker incident. That address must claim before the moderator submits the correction.

Impact:

The mistakenly named attacker receives all staked principal and bonus. The intended attacker receives nothing, and the transfer cannot be corrected or recovered.

Proof of Concept

Add the following test to test/unit/AttackerCorrectionRace.t.sol and run:

forge test --offline --match-contract AttackerCorrectionRaceTest -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from
"@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from
"src/interfaces/IConfidencePool.sol";
import {PoolStates} from
"src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from
"test/helpers/BaseConfidencePoolTest.sol";
contract AttackerCorrectionRaceTest is BaseConfidencePoolTest {
function testPoC_wrongAttackerClaimsEntirePoolAndPreventsCorrection()
external
{
address intendedAttackerA = makeAddr("intendedAttackerA");
address mistakenlyNamedAttackerB =
makeAddr("mistakenlyNamedAttackerB");
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// The moderator intends to reward A but accidentally selects B.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
mistakenlyNamedAttackerB
);
// B immediately claims the complete pool.
vm.prank(mistakenlyNamedAttackerB);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(mistakenlyNamedAttackerB),
150 * ONE,
"wrong attacker drains the full pool"
);
assertEq(
token.balanceOf(intendedAttackerA),
0,
"intended attacker receives nothing"
);
assertEq(
token.balanceOf(address(pool)),
0,
"pool is empty after the mistaken claim"
);
assertTrue(
pool.claimsStarted(),
"mistaken claim permanently closes correction"
);
// The moderator can no longer replace B with A.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
intendedAttackerA
);
}
}

The test passes, showing that attacker B drains the complete pool and permanently prevents correction.

Recommended Mitigation

Wire _assertClaimsOpen() into claimSurvived / claimAttackerBounty / claimCorrupted / sweeps. Drop the claimsStarted latch for re-flag gating (or keep it only as a post-window safety check). The fix may look like:

uint256 public constant CORRECTION_WINDOW = 1 hours;
uint32 public firstFlaggedAt; // set once, never reset
function flagOutcome(...) external onlyModerator {
if (outcome != UNRESOLVED && block.timestamp > firstFlaggedAt + CORRECTION_WINDOW) {
revert OutcomeAlreadySet();
}
// ... validate & write outcome / attacker / snapshots ...
if (firstFlaggedAt == 0) firstFlaggedAt = uint32(block.timestamp);
}
function _assertClaimsOpen() internal view {
if (outcome == UNRESOLVED) revert OutcomeNotSet();
if (firstFlaggedAt == 0 || block.timestamp <= firstFlaggedAt + CORRECTION_WINDOW) {
revert CorrectionWindowActive();
}
}

Support

FAQs

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

Give us feedback!