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.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.