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

Re-flagging a corrected attacker inherits an already-elapsed `corruptedClaimDeadline`, permanently locking out a legitimately-named whitehat

Author Revealed upon completion

Root + Impact

corruptedClaimDeadline is derived once from _firstGoodFaithCorruptedAt and is never recomputed on subsequent re-flags, so if the moderator corrects the named attacker more than CORRUPTED_CLAIM_WINDOW after the original flag, the newly-named attacker is bound to a deadline that has already passed before they can ever call claimAttackerBounty().

Description

flagOutcome supports pre-claim re-flagging so the moderator can correct a mistake, including renaming the attacker on a good-faith CORRUPTED outcome, as long as no claim has yet been made (claimsStarted == false). This is by design: a claim is treated as the value-movement event that finalizes the outcome, not a timer.
The issue is in how the bounty window is anchored across that correction. _firstGoodFaithCorruptedAt is set exactly once, on the first time the pool ever enters good-faith CORRUPTED, and is deliberately never reset.

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
// forge-lint: disable-next-line(unsafe-typecast)
@> _firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// Reuses the original window on re-entry — which may already be in the past, leaving
// nothing to claim. Intended: the deadline must never be extendable.
// forge-lint: disable-next-line(unsafe-typecast)
@> corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}

corruptedClaimDeadline is always computed from that original timestamp. If the moderator's correction happens after the original 180-day window has already elapsed e.g., they initially mis-typed the attacker's address, later discover the mistake, and fix it well past 180 days later, the freshly-corrected attacker is handed a corruptedClaimDeadline that is already in the past.
claimAttackerBounty() will revert ClaimWindowExpired() on their very first call,

function claimAttackerBounty() external nonReentrant { // @audit-ok
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();
}

and sweepUnclaimedCorrupted() becomes immediately callable by anyone, sweeping the entire pool (stake + bonus) to recoveryAddress before the correctly-named whitehat ever gets a chance to claim.

Risk

Likelihood:
This occurs whenever a moderator's correction to the attacker address lands more than CORRUPTED_CLAIM_WINDOW (180 days) after the pool's first good-faith CORRUPTED flag a realistic timeline for a DAO-governed correction process.

Impact:
The legitimately-named whitehat attacker permanently loses their entire bounty entitlement (up to the full pool: stake + bonus) with no on-chain recourse. claimAttackerBounty() cannot succeed for them under any circumstance once the inherited deadline has passed.

Proof Of Concept

None

Recommended Mitigation

Only reset the window when the attacker identity actually changes. Re-flagging the same attacker still reuses the original timestamp (preserving the anti-gaming protection), but naming a different attacker now starts a fresh CORRUPTED_CLAIM_WINDOW instead of inheriting one that may already be expired.

if (willBeGoodFaithCorrupted) {
- if (_firstGoodFaithCorruptedAt == 0) {
- // forge-lint: disable-next-line(unsafe-typecast)
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
- }
- // forge-lint: disable-next-line(unsafe-typecast)
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ // Only reuse the original window when re-flagging the SAME attacker (the anti-gaming
+ // case this guard exists for). A correction to a different attacker starts a fresh
+ // window, since it is a distinct entitlement, not an extension of the old one.
+ if (_firstGoodFaithCorruptedAt == 0 || attacker_ != attacker) {
+ // forge-lint: disable-next-line(unsafe-typecast)
+ _firstGoodFaithCorruptedAt = uint32(block.timestamp);
+ }
+ // forge-lint: disable-next-line(unsafe-typecast)
+ corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}

Support

FAQs

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

Give us feedback!