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

No grace period between a good-faith CORRUPTED flag and the named attacker's ability to drain the entire pool, with no recourse if the address is wrong

Author Revealed upon completion

Root + Impact

Description

  • - `claimAttackerBounty()` has no delay or grace period between the moment `flagOutcome` flags a good-faith CORRUPTED outcome and the named attacker's eligibility to claim. - The named attacker can call `claimAttackerBounty()` in the very next transaction after the flag lands, draining the entire snapshot (stake + bonus) immediately.

// Root cause in the codebase with @> marks to highlight the relevant section
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();
// @> no minimum-elapsed-time check exists here

Risk

Likelihood:

  • A moderator flagging good-faith CORRUPTED must supply an attacker address by hand; address typos/copy-paste errors are a common, realistic class of on-chain mistake.


  • OutcomeFlagged is a public event, so any party is capable of monitoring for a flag and reacting instantly if the named address is claimable by them.

Impact:

  • The entire pool (stakers' principal + bonus, not just the attacker's fair bounty) can be drained in the transaction immediately following the flag.


  • Because claimAttackerBounty sets claimsStarted = true on any nonzero payout, flagOutcome's re-flag guard permanently blocks any moderator correction from that point on — there is no recovery path once a single claim has landed.

Proof of Concept:

function test_zeroTimelockOnGoodFaithCorruptedClaim() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 received = token.balanceOf(attacker) - attackerBefore;
assertEq(
received,
180 * ONE,
"BUG CONFIRMED: full pool drained with zero grace period"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}

This test stakes 100 ETH (alice) and 50 ETH (bob), contributes 30 ETH bonus (carol), then transitions the registry through UNDER_ATTACK to CORRUPTED. The moderator flags good-faith CORRUPTED naming attacker with zero elapsed time and no grace period. attacker immediately calls claimAttackerBounty() in the very next call and receives the full 180 ETH pool (100 + 50 stake + 30 bonus). The final assertion confirms the moderator can no longer correct this via a re-flag to SURVIVED, since claimsStarted is already true.

Recommended Mitigation:

+ uint32 public constant CLAIM_GRACE_PERIOD = 1 days;
+
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();
+ if (block.timestamp < outcomeFlaggedAt + CLAIM_GRACE_PERIOD) revert ClaimTooEarly()
Adding CLAIM_GRACE_PERIOD enforces a minimum delay (e.g. 24 hours) between the good-faith CORRUPTED flag and the earliest the named attacker can claim. This gives the moderator a window to notice and correct an address error via re-flag before claimsStarted locks the outcome, without weakening the existing 180-day claim deadline or its anti-extension protection.

Support

FAQs

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

Give us feedback!