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

Re-flag cycling truncates a newly-named attacker's claim window

Author Revealed upon completion

Root + Impact

Description

  • The protocol allows the moderator to correct typos or errors in flagOutcome by re-flagging before the first claim occurs. A good-faith CORRUPTED flag gives the named attacker a 180-day window (CORRUPTED_CLAIM_WINDOW) to claim their bounty before it becomes permissionlessly sweepable by the sponsor.


  • If the moderator flags CORRUPTED, then SURVIVED, and then corrects back to CORRUPTED naming a different attacker, the new attacker does not get a fresh 180-day window. The _firstGoodFaithCorruptedAt timestamp never resets, so the new attacker's claim window is silently truncated to whatever remains of the original 180 days.

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
// @> Root cause: _firstGoodFaithCorruptedAt never resets, even if the attacker changes during a re-flag
// 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.
// Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope.
// forge-lint: disable-next-line(unsafe-typecast)
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);

Risk

Likelihood:

  • The moderator flags a good-faith CORRUPTED outcome, then temporarily re-flags to SURVIVED or another outcome due to new information or further investigation.

  • The moderator later discovers a different whitehat was the true cause and re-flags back to CORRUPTED with the new attacker late in the original 180-day window.

Impact:

  • The newly named attacker has their claim window severely truncated, potentially leaving them only days or hours to claim their rightfully earned bounty.

  • If the new attacker misses this unexpectedly short window, their entire bounty is lost and can be swept to the sponsor's recoveryAddress via sweepUnclaimedCorrupted.

Proof of Concept

The following sequence of events demonstrates how a valid sequence of moderator actions permanently anchors the claim deadline to the very first flag, unfairly penalizing a newly discovered attacker.

Because _firstGoodFaithCorruptedAt is only written when it equals 0, any subsequent re-flags back into the CORRUPTED state reuse the original timestamp, regardless of how much time has passed or if the recipient has changed.

// t0: Moderator flags CORRUPTED, attacker = X
// -> _firstGoodFaithCorruptedAt is set to t0.
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attackerX);
// t0 + 170 days: Moderator realizes X was wrong, flags SURVIVED while investigating
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// t0 + 175 days: Moderator confirms Y was the true whitehat, flags CORRUPTED again
// -> _firstGoodFaithCorruptedAt remains t0.
// -> corruptedClaimDeadline = t0 + 180 days.
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attackerY);
// Attacker Y now only has 5 days to claim instead of the intended 180 days.
// If Y fails to claim within 5 days, the sponsor sweeps the entire pool via sweepUnclaimedCorrupted().

Recommended Mitigation

To resolve this issue while maintaining the invariant that a single attacker cannot get their deadline extended, the contract should track the previous attacker. If the moderator re-flags and names a new attacker, the _firstGoodFaithCorruptedAt timestamp should be reset to block.timestamp to guarantee the new whitehat receives their full 180-day claim window.

The diff below implements this by temporarily storing the oldAttacker before the state update, and expanding the zero-check to also reset the timestamp if attacker_ != oldAttacker.

- remove this code
+ add this code
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
+ address oldAttacker = attacker;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
if (willBeGoodFaithCorrupted) {
- if (_firstGoodFaithCorruptedAt == 0) {
+ if (_firstGoodFaithCorruptedAt == 0 || attacker_ != oldAttacker) {
// forge-lint: disable-next-line(unsafe-typecast)
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}

Support

FAQs

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

Give us feedback!