Normal behavior: flagOutcome allows the moderator to re-flag an outcome once, pre-claim, so they can correct a mistaken call (e.g. an outcome that turns out to actually be an in-scope, good-faith CORRUPTED after first being flagged SURVIVED). When a good-faith CORRUPTED is (re-)flagged, bountyEntitlement is set to snapshotTotalStaked + snapshotTotalBonus — the entire pool, principal plus bonus — and the named attacker/whitehat is meant to be able to claim all of it via claimAttackerBounty(). This "full pool to the whitehat" guarantee is stated directly in the contract's own NatSpec.
The specific issue: snapshotTotalBonus is read from the live totalBonus variable at the moment flagOutcome runs — including on a re-flag. But sweepUnclaimedBonus() is deliberately not gated by claimsStarted (so that a 1-wei donation can't be used to grief the moderator's re-flag window), and it does permanently decrement totalBonus whenever the bonus looks "unreserved" (e.g. riskWindowStart == 0, the accepted "no observed risk" liveness gap from docs/DESIGN.md §5). This creates a window: after a provisional SURVIVED flag but before the moderator's correction to good-faith CORRUPTED, anyone can permissionlessly call sweepUnclaimedBonus(), sending the entire bonus to the sponsor-controlled recoveryAddress and zeroing totalBonus — before the re-flag ever reads it. The moderator's correction then computes bountyEntitlement from an already-drained totalBonus, silently shorting the rightful whitehat by the full bonus amount, which has already gone to the sponsor instead.
Likelihood:
Occurs whenever a moderator's first flag (SURVIVED, chosen when the registry state is legitimately CORRUPTED but judged out-of-scope) is later corrected to good-faith CORRUPTED — a scenario the contract's own re-flag mechanism exists specifically to support, and one made more likely by the "no observed risk" liveness gap already accepted in docs/DESIGN.md §5.
sweepUnclaimedBonus() is fully permissionless and has no reason not to be called immediately after any SURVIVED/EXPIRED flag — it is the documented, intended way to clear dust/unreserved bonus, so a bot sweeping eagerly after every flag event will trigger this by default, with no adversarial coordination required.
The exploit window is exactly the moderator's own re-flag window — the same window docs/DESIGN.md explicitly says exists to let the moderator fix a mistaken call, meaning the bug is most likely to fire precisely when the protocol most needs the correction mechanism to work correctly.
Impact:
Directly contradicts the contract's own documented guarantee that a good-faith CORRUPTED entitles the named attacker/whitehat to the full pool (principal + bonus) — the whitehat instead recovers only the principal-equivalent portion.
The missing bonus is not lost to the system — it has already gone to recoveryAddress, which is sponsor-controlled — so the sponsor benefits directly at the expense of the party the protocol is designed to reward for legitimately reporting/rescuing an in-scope breach.
Undermines the whitehat incentive the entire CORRUPTED-good-faith mechanism exists to provide: a rational whitehat who anticipates this cannot trust that a moderator correction will actually make them whole, weakening the core security-rescue incentive of the protocol.
Create a Test file at test/unit/Audit.ReflagBonusDesyncTest.t.sol
and paste the below code
Run it:
You'll see the trace confirm, step by step: totalBonus goes from 50e18 to 0 inside sweepUnclaimedBonus(); claimsStarted stays false across that call; the moderator's second flagOutcome succeeds (not blocked); bountyEntitlement() reads back exactly 100e18 instead of the expected 150e18; and the whitehat's final balance matches the short entitlement while recovery walks away with the 50e18 that should have been the whitehat's.
Two independent fixes, either of which closes the gap; using both is the more robust option since they patch it from different angles.
Fix 1 — freeze the bonus figure a re-flag can see, not just the outcome. Snapshot totalBonus (and totalEligibleStake) once, on the first flag, and reuse that frozen figure on any re-flag, the same way _firstGoodFaithCorruptedAt/corruptedClaimDeadline are already frozen on first entry and deliberately not refreshed:
This is the more surgical fix — it doesn't touch sweepUnclaimedBonus() at all, and it matches the pattern the contract already uses elsewhere (freeze-on-first-entry) instead of introducing a new one.
Fix 2 — don't let the "unreserved" bonus be swept while a re-flag is still possible. Block sweepUnclaimedBonus() during the same window flagOutcome's re-flag guard checks, i.e. require claimsStarted before any bonus can be swept away, closing the gap from the other side:
Note this second fix reintroduces the exact griefing concern the original code's comment warns about (a 1-wei donation flipping claimsStarted to lock the re-flag window) unless it's paired with a change that makes sweepUnclaimedBonus() itself not toggle claimsStarted — which it already doesn't, so Fix 2 as written only blocks the sweep, it doesn't reintroduce the griefing vector. Fix 1 alone is sufficient and doesn't carry that trade-off, so it's the recommended primary fix.
Best regards,
Aliyu
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.