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

Correcting a good-faith CORRUPTED attacker address does not reset the claim deadline, permanently locking out the correct attacker

Author Revealed upon completion

Root + Impact

Description

  • _firstGoodFaithCorruptedAt is set once, on the first good-faith CORRUPTED flag, and is
    never reset on any later re-flag. This is intentional as an anti-extension measure, but it
    also defeats a legitimate correction to the named attacker address.

  • If a moderator flags good-faith CORRUPTED naming the wrong attacker (e.g. a typo), and
    later corrects it, corruptedClaimDeadline stays anchored to the original, wrong flag.

// Root cause in the codebase with @> marks to highlight the relevant
if (willBeGoodFaithCorrupted) {
@> if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// @> deadline anchors to the FIRST flag and never resets on a later correction
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}

Risk

Likelihood:

  • Moderators correcting a mistaken attacker address is a realistic operational scenario.

  • Original observation ([friend]'s finding): the lockout triggers via a SURVIVED detour
    and elapsed time (flag A → re-flag SURVIVED → wait → re-flag B).

  • Extension (this submission): the same lockout is reachable via a simpler, more direct
    path — a moderator correcting A to B with zero elapsed time and zero prior claims,
    no SURVIVED detour required.

Impact:

  • The correctly-named whitehat is permanently denied their bounty.

  • The entire pool (stakers' principal + bonus) becomes immediately sweepable to
    recoveryAddress instead of ever reaching the rightful claimant.

Proof of Concept

function test_bountyWindow_directCorrectionStillAnchorsToFirstFlag() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint32 deadlineAfterFirstFlag = pool.corruptedClaimDeadline();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attackerB);
uint32 deadlineAfterCorrection = pool.corruptedClaimDeadline();
assertEq(deadlineAfterCorrection, deadlineAfterFirstFlag,
"BUG CONFIRMED: correction did not reset the claim deadline");
vm.warp(deadlineAfterCorrection + 1);
vm.prank(attackerB);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE,
"entire pool swept away from the correct attacker");
}

This test flags good-faith CORRUPTED naming attacker (standing in for a moderator typo),
then immediately re-flags with attackerB as the correction -- no SURVIVED detour, no
elapsed time, no prior claims. The deadline is confirmed identical across the correction.
After warping past the (stale) deadline, attackerB's claim reverts ClaimWindowExpired, and
sweepUnclaimedCorrupted immediately sends the full 120 ETH pool to recoveryAddress instead.

Recommended Mitigation

if (willBeGoodFaithCorrupted) {
+ if (attacker_ != attacker) {
+ _firstGoodFaithCorruptedAt = 0;
+ }
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}

Support

FAQs

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

Give us feedback!