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

Re-Flag Window Lets a Moderator Permanently Zero Out the Real Attacker's Bounty Claim Window ("Anchor-and-Flip" Griefing)

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

Recommended Mitigation

- remove this code
+ add this code

Severity
Medium–High (conditional entirely on moderator trust)

Likelihood
Low–Medium

Impact
Medium–High

Description
flagOutcome() sets _firstGoodFaithCorruptedAt the first time a good-faith CORRUPTED flag fires, and by design never resets it — even if the outcome is later flipped away and back.

The code comments acknowledge this is intentional — the claim deadline must never be extendable — and even note the consequence that re-entering the branch "reuses the original window on re-entry, which may already be in the past." What the comments don't cover is that this sticky-anchor property, combined with the re-flag window staying open until claimsStarted becomes true, gives the moderator a deliberate griefing tool rather than just an accidental-typo risk:

Impact: A pool that is genuinely, correctly resolved as CORRUPTED with a real identified attacker permanently and silently denies that attacker their bounty. Nothing about the transactions looks obviously malicious in isolation — a legitimate moderator correcting a typo produces an identical on-chain trace to one deliberately burning the anchor — making this hard to distinguish from benign moderator behavior after the fact.

Likelihood: Requires the onlyModerator role to act maliciously or be compromised, so it isn't externally reachable. But the barrier to execution is trivial — two ordinary-looking transactions, no privileges beyond what the role already holds — and the documented design intent ("prevent extending the window") suggests this specific "flag-then-flip-then-wait" abuse path wasn't fully considered, only the accidental-typo case.

Mitigation
Delay anchoring until the determination is actually final, rather than on the first flag regardless of whether it's later reversed.

This preserves the legitimate anti-extension property — once the anchor is set post-finality, it's still never overwritten — while closing the flag-and-flip trick, since a reversible flag by definition happens before claimsStarted is true.

Remove this code

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

Add this code

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0 && claimsStarted) {
// Only anchor once the re-flag window is actually closed — i.e. once
// someone has begun claiming, meaning this determination is final and
// not just a transient flag that could be flipped away before anyone acts.
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = _firstGoodFaithCorruptedAt == 0
? 0
: uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}

emit GoodFaithCorruptedAnchored(_firstGoodFaithCorruptedAt);
// loud, dedicated event for monitoring

POC

contract AnchorAndFlipPoCTest is Test {
FlagOutcomeHarness pool;

address moderator = address(0xM0D);
address throwaway = address(0xDEAD);
address realAttacker = address(0xBEEF);

function setUp() public {
    pool = new FlagOutcomeHarness(moderator);
}

/// @notice Core PoC: moderator anchors _firstGoodFaithCorruptedAt early with a
/// throwaway flag, flips away before anyone claims, waits out the whole
/// claim window, then makes the real determination — and the real
/// attacker's bounty claim is dead on arrival.
function test_Bug_AnchorAndFlipZeroesRealAttackerBountyWindow() public {
    uint256 t0 = block.timestamp;

    // Step 1: moderator anchors the timestamp with a throwaway flag the
    // instant CORRUPTED is (or might be) hit.
    vm.prank(moderator);
    pool.flagOutcome(FlagOutcomeHarness.Outcome.CORRUPTED, true, throwaway);

    assertEq(pool._firstGoodFaithCorruptedAt(), t0, "anchor burned in at t0");
    assertFalse(pool.claimsStarted(), "nobody has claimed yet");

    // Step 2: moderator immediately flips away before anyone claims.
    // Allowed because claimsStarted is still false.
    vm.prank(moderator);
    pool.flagOutcome(FlagOutcomeHarness.Outcome.SURVIVED, false, address(0));

    assertEq(uint256(pool.corruptedClaimDeadline()), 0, "deadline reset");
    assertEq(pool._firstGoodFaithCorruptedAt(), t0, "anchor still burned in, unchanged");

    // Step 3: 180 days pass with no resolution.
    vm.warp(t0 + 180 days);

    // Step 4: moderator (or successor) makes the real, legitimate
    // determination with the real attacker.
    vm.prank(moderator);
    pool.flagOutcome(FlagOutcomeHarness.Outcome.CORRUPTED, true, realAttacker);

    // Anchor was NOT updated (still t0), so deadline is t0 + window,
    // which is exactly now / already effectively expired.
    assertEq(pool._firstGoodFaithCorruptedAt(), t0);
    assertEq(uint256(pool.corruptedClaimDeadline()), t0 + FlagOutcomeHarness.CORRUPTED_CLAIM_WINDOW);

    // Push a little further past the dead-on-arrival deadline to remove
    // any ambiguity, then the real attacker tries to claim.
    vm.warp(t0 + 180 days + 1);

    vm.prank(realAttacker);
    vm.expectRevert("bounty claim window expired");
    pool.claimAttackerBounty();

    // Pool is genuinely CORRUPTED with a real identified attacker, but the
    // bounty is permanently unclaimable. Funds will fall through to
    // recoveryAddress via claimCorrupted/sweepUnclaimedCorrupted instead.
    assertEq(uint256(pool.outcome()), uint256(FlagOutcomeHarness.Outcome.CORRUPTED));
    assertEq(pool.corruptedReserve(), 110 ether, "funds still reserved, just never reach the attacker");
}

/// @notice Control: without the throwaway anchor-and-flip, the same
/// timeline and same real determination gives the real attacker a full,
/// live claim window.
function test_Control_NoThrowawayFlagGivesRealAttackerFullWindow() public {
    uint256 t0 = block.timestamp;
    vm.warp(t0 + 180 days);

    vm.prank(moderator);
    pool.flagOutcome(FlagOutcomeHarness.Outcome.CORRUPTED, true, realAttacker);

    uint256 anchor = pool._firstGoodFaithCorruptedAt();
    assertEq(anchor, t0 + 180 days, "anchor set fresh at real determination");
    assertEq(
        uint256(pool.corruptedClaimDeadline()),
        anchor + FlagOutcomeHarness.CORRUPTED_CLAIM_WINDO


    );

    vm.prank(realAttacker);
    pool.claimAttackerBounty(); // succeeds, full window available

    assertTrue(pool.claimsStarted());
}

}

Support

FAQs

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

Give us feedback!