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

Permissionless claimCorrupted() on bad-faith flag forecloses moderator's good-faith correction, diverting whitehat bounty to recoveryAddress

Author Revealed upon completion

Description

// @audit claimCorrupted is permissionless with no caller check; sweeps entire pool in same tx as bad-faith flag
// @audit Good-faith correction window is caller-gated (claimAttackerBounty: msg.sender == attacker)
// @audit Bad-faith has NO equivalent protection — any caller can foreclose the correction

DESIGN.md §4 guarantees the moderator can correct an outcome by re-flagging before the first claim. For a good-faith CORRUPTED flag this window is enforceable because claimAttackerBounty is gated to msg.sender == attacker. For a bad-faith CORRUPTED flag the window is not: claimCorrupted() (line 408) has no caller check.

// @audit Re-flag gate: only checks claimsStarted
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// @audit Good-faith: caller-gated, protected
if (msg.sender != attacker) revert NotAttacker();
// @audit Bad-faith: NO caller check. Permissionless sweep + latch in same call.
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (!claimsStarted) claimsStarted = true; // @audit latched BEFORE transfer
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

The function is permissionless, sweeps the entire pool to recoveryAddress, and latches claimsStarted = true in the same call. The sponsor, who controls recoveryAddress (mutable anytime at line 611), executes the sweep in the same block a bad-faith flag lands, permanently foreclosing the moderator's correction. The whitehat, entitled to the entire pool under §12, receives nothing. This is realistic: an exploit initially flagged as blackhat is later confirmed as a whitehat disclosure — exactly the correction §4's re-flag window exists to protect.

Proof of Concept

The PoC below shows a zero-stake caller sweeping the pool via permissionless claimCorrupted() immediately after a bad-faith flag. The moderator's good-faith correction reverts, and the whitehat receives nothing — the full pool diverts to recoveryAddress.

// PoC: test/unit/PocBadFaithClaimCorruptedRace.t.sol
vm.prank(alice);
pool.stake(100 ether);
vm.prank(carol);
pool.contributeBonus(50 ether);
mockRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.claimCorrupted(); // @audit permissionless, same block
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.prank(whitehat);
vm.expectRevert();
pool.claimAttackerBounty();
assertEq(token.balanceOf(recoveryAddress), 150 ether);
assertEq(token.balanceOf(whitehat), 0);

Risk

claimCorrupted() is permissionless with no caller check. The sponsor controls recoveryAddress (mutable anytime at line 611) and is uniquely incentivized: the bad-faith sweep sends the entire pool there. The call is immediate and can execute same-block as the bad-faith flag — the moderator has zero window to correct.

Impact

100% of the pool diverted to sponsor-controlled recoveryAddress instead of the legitimate whitehat. Irreversible once claimsStarted is latched. Violates §12 and §10.

Recommended Mitigation

Restrict the first claimCorrupted() after a bad-faith flag to recoveryAddress or the moderator, symmetric to how claimAttackerBounty is caller-gated.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ if (!goodFaith && !claimsStarted && msg.sender != recoveryAddress && msg.sender != outcomeModerator) {
+ revert MustClaimBountyFirst();
+ }
uint256 toSweep = stakeToken.balanceOf(address(this));
...
}

Support

FAQs

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

Give us feedback!