FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

sweepUnclaimedBonus drains bonus before moderator re-flag, reducing whitehat attacker bounty

Author Revealed upon completion

Root + Impact

sweepUnclaimedBonus() intentionally does not set claimsStarted, allowing the moderator's pre-claim re-flag window to remain open. However, it does decrement totalBonus when riskWindowStart == 0. If the moderator first flags SURVIVED (judging the breach out-of-scope), anyone can sweep the bonus to recoveryAddress, draining totalBonus to zero. When the moderator then re-flags to CORRUPTED (good-faith), the new snapshot captures totalBonus == 0, so bountyEntitlement excludes the bonus entirely. The whitehat attacker loses their entitled bonus portion.

Description

The attack requires riskWindowStart == 0 (registry skipped active-risk states). Under this condition:

  1. Moderator flags SURVIVED — permitted because CORRUPTED registry state is accepted for SURVIVED when the breach is out-of-pool-scope (DESIGN.md §8)

  2. Anyone calls sweepUnclaimedBonus():

    • outcome == SURVIVED passes the gate (line 475)

    • riskWindowStart == 0 means bonus is not owed to stakers (line 499)

    • totalBonus -= amount drains bonus to zero (line 499)

    • claimsStarted is intentionally NOT set (line 503-504)

  3. Moderator discovers error and re-flags CORRUPTED with goodFaith=true:

    • claimsStarted == false so re-flag is allowed (line 327)

    • snapshotTotalBonus = totalBonus = 0 (line 358) — bonus already gone

    • bountyEntitlement = snapshotTotalStaked + 0 — attacker loses bonus

The comment on line 503 explains the design rationale: setting claimsStarted would let a 1 wei donation block the moderator's re-flag window. But the current design allows real value (the entire bonus pool) to leave the contract during that same window, corrupting the re-flag snapshot.

Risk

Likelihood:

  • Requires riskWindowStart == 0 (registry skips active-risk states)

  • Requires moderator to flag SURVIVED then correct to CORRUPTED

  • Anyone can call sweepUnclaimedBonus between the two flags

Impact:

  • Whitehat attacker loses their entire bonus entitlement

  • In the PoC: 30 ONE out of 180 ONE total (16.7% loss)

  • Bonus portion scales with totalBonus — could be larger in production

Proof of Concept

function testSweepDrainsBonusBeforeReflag() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0, "bonus drained");
assertFalse(pool.claimsStarted(), "claimsStarted still false");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 entitlement = pool.bountyEntitlement();
emit log_named_uint("bountyEntitlement", entitlement);
emit log_named_uint("lost_bonus", 180 * ONE - entitlement);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(entitlement, 150 * ONE, "attacker lost 30 ONE bonus");
}

Result: PASS — bountyEntitlement: 150 ONE (expected 180), lost_bonus: 30 ONE, attacker_received: 150 ONE

Recommended Mitigation

Snapshot totalBonus before decrementing in sweepUnclaimedBonus, or prevent sweep during the re-flag window:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Block sweep while re-flag is still possible
+ if (!claimsStarted) revert ReflagWindowStillOpen();
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

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.

Support

FAQs

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

Give us feedback!