FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

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();

Support

FAQs

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

Give us feedback!