Describe the normal behavior in one or more sentences
Explain the specific issue or problem in one or more sentences
Likelihood:
Reason 1 // Describe WHEN this will occur (avoid using "if" statements)
Reason 2
Impact:
Impact 1
Impact 2
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());
}
}
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.