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

sweepUnclaimedBonus during the re-flag window reduces the good-faith attacker's bounty entitlement

Author Revealed upon completion

Root + Impact

Description

  • The protocol provides the moderator a pre-claim "re-flag window" to correct mistakes in the outcome. During this window, any unreserved bonus can be swept to the sponsor's recoveryAddress via sweepUnclaimedBonus(), which intentionally avoids locking the re-flag window (it doesn't set claimsStarted).

  • Because sweepUnclaimedBonus() decrements totalBonus, if the moderator flags a non-CORRUPTED outcome, the sponsor sweeps the bonus, and the moderator subsequently corrects the outcome to CORRUPTED, the flagOutcome function re-snapshots totalBonus at 0. This irreversibly lowers the attacker's bountyEntitlement, redirecting funds that rightfully belong to the attacker into the sponsor's wallet.

function sweepUnclaimedBonus() external nonReentrant {
// ...
// @> Root cause: totalBonus is decremented here, which understates the bountyEntitlement if a re-flag to CORRUPTED occurs later
if (amount > 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

  • The moderator flags SURVIVED or EXPIRED (e.g., misjudging an incident as out-of-scope).

  • The sponsor (or any observer) calls sweepUnclaimedBonus() before any claims are made, sweeping the bonus to the recoveryAddress.

  • The moderator realizes the incident was actually an in-scope breach and re-flags to a good-faith CORRUPTED outcome.

Impact:

  • The good-faith whitehat attacker loses the entire bonus portion of their bounty.

  • The sponsor successfully extracts funds from the pool that should have gone to the attacker, breaking the core invariant that the attacker is entitled to the full pool (stake + bonus) in a good-faith scenario.

Proof of Concept

The following sequence demonstrates how the sponsor can extract the bonus while the moderator is investigating an outcome.

// t0: Pool has 100 stake and 50 bonus. Total balance = 150.
// Moderator mistakenly flags SURVIVED (believes breach is out of scope).
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Sponsor immediately sweeps the unclaimed bonus to recoveryAddress.
// totalBonus drops from 50 to 0. Balance drops to 100.
pool.sweepUnclaimedBonus();
// t1: Moderator discovers breach was in-scope, corrects to CORRUPTED.
// flagOutcome re-snapshots: snapshotTotalBonus = totalBonus = 0.
// bountyEntitlement = snapshotTotalStaked (100) + snapshotTotalBonus (0) = 100.
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Attacker claims bounty but only receives 100 instead of 150.
// The sponsor effectively extracted 50 tokens from the attacker's rightful bounty.

Recommended Mitigation

To resolve this issue without breaking the donation-griefing protection inside sweepUnclaimedBonus (which explicitly avoids setting claimsStarted), flagOutcome should only snapshot the accounting variables on the first flag, preventing the swept bonus from corrupting a later correction.

- remove this code
+ add this code
bool public expiryLocked;
+ bool public hasFlagged;
// ... Inside flagOutcome()
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
- snapshotTotalStaked = totalEligibleStake;
- snapshotTotalBonus = totalBonus;
- snapshotSumStakeTime = sumStakeTime;
- snapshotSumStakeTimeSq = sumStakeTimeSq;
+ if (!hasFlagged) {
+ snapshotTotalStaked = totalEligibleStake;
+ snapshotTotalBonus = totalBonus;
+ snapshotSumStakeTime = sumStakeTime;
+ snapshotSumStakeTimeSq = sumStakeTimeSq;
+ hasFlagged = true;
+ }
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;

Support

FAQs

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

Give us feedback!