sweepUnclaimedBonus() can move accounted bonus to recoveryAddress while the moderator’s pre-claim re-flag window is still open. If the moderator later corrects SURVIVED to good-faith CORRUPTED, the new snapshot uses the already-reduced totalBonus, so the attacker bounty excludes bonus that was part of the pool before the correction.
Vulnerable code:
flagOutcome() intentionally allows the moderator to re-flag before any claim starts:
The problem is that sweepUnclaimedBonus() can move real accounted bonus before that re-flag window is closed:
When riskWindowStart == 0, the function treats the bonus as sweepable, transfers it to recoveryAddress, and reduces totalBonus. But it does not set claimsStarted, so the moderator can still re-flag afterward. A later good-faith CORRUPTED flag snapshots the already-drained totalBonus, reducing the attacker’s bounty.
The code comments show that the moderator is allowed to fix an outcome only before users start relying on the current distribution.
sweepUnclaimedBonus() does not set claimsStarted because otherwise someone could send a tiny amount of tokens directly to the pool and use that donation to block the moderator from correcting a mistake. That part makes sense.
The problem is that the function does not separate a direct donation from the pool’s real recorded bonus.
In the second test, the pool has 100 tokens of stake, 50 tokens of bonus, and a 25-token direct donation. The sweep sends 75 tokens to recoveryAddress, which includes both the donation and the full 50-token bonus. Even after that, the moderator can still change the outcome. When the outcome is corrected to good-faith CORRUPTED, the attacker receives only the 100-token stake instead of 150 tokens.
The third test makes the issue even clearer because there is no donation at all. The pool has no stake and only a 50-token bonus. That bonus is fully swept, but the moderator can still correct the outcome afterward. Since the new snapshot sees zero stake and zero bonus, the attacker bounty becomes zero.
So this is not only about safely removing random tokens sent by outsiders. The same logic also lets the pool’s recorded bonus leave before the outcome is final. Once that bonus has been moved, a later correction should not be allowed to calculate the bounty from the reduced amount.
So the intended rule is not “re-flagging remains open after pool value moves.” The intended rule is that re-flagging stays open before claims lock in a distribution.
sweepUnclaimedBonus() avoids setting claimsStarted to prevent a tiny donation from griefing the moderator’s correction window:
That makes sense for dust or unaccounted donations. It does not cover the PoC case, where the swept amount is the real 50 * ONE bonus added through contributeBonus(). Once that accounted bonus leaves the pool, a later corrected snapshot should not silently lose it.
This requires a particular order of events: the moderator first flags the pool as SURVIVED or EXPIRED, someone calls sweepUnclaimedBonus() before any claim, and the moderator later corrects the result to good-faith CORRUPTED.
The sequence is specific, but every step is allowed by the current code. The sweep is permissionless, and it does not close the moderator’s correction window.
The attacker receives less than the bounty that would have been available before the bonus was swept.
In the second test, the attacker should receive the 100-token stake plus the 50-token bonus. Instead, the bonus is sent to recoveryAddress, and the attacker receives only 100 tokens. The attacker loses 50 tokens from the corrected bounty.
In the third test, the pool contains only a 50-token bonus and no stake. After the bonus is swept, the corrected CORRUPTED outcome gives the attacker nothing. The entire bounty is removed.
The tokens are not burned or stuck. They are sent to recoveryAddress. The issue is that they go to the wrong side of the final distribution, which reduces or completely removes the attacker’s bounty..
test/unit/AuditReflagSweepBonusTest.t.sol
Manual review, Foundry test.
Close the re-flag window when sweepUnclaimedBonus() moves accounted bonus, while still allowing harmless dust/donation sweeps to avoid blocking moderator correction.
This keeps the intended donation/dust behavior intact, but once real accounted bonus leaves the pool, the outcome becomes final just like other value-moving paths
Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.
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.