FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

ermissionless sweepUnclaimedBonus moves the bonus without latching finality, so a moderator's re-flag correction leaves the named whitehat robbed of the entire bonus

Author Revealed upon completion

Root + Impact

Description

  • flagOutcome may be re-flagged before the first claim so the moderator can fix a typo'd or premature outcome "before any participant locks in the wrong distribution" (docs/DESIGN.md §4), and §4 states finality is "correctly tied to value movement" via the claimsStarted latch. On a good-faith CORRUPTED re-flag the whole pool (stake + bonus) is re-snapshotted as the named whitehat's bountyEntitlement.

  • sweepUnclaimedBonus() moves real contributed bonus out of the pool to recoveryAddress but intentionally does not set claimsStarted. When a SURVIVED outcome was flagged with riskWindowStart == 0 (the correctable mis-flag §4 exists for), the reserve calculation excludes the bonus, so a permissionless sweep drains the entire snapshotTotalBonus to recoveryAddress and zeroes totalBonus while the correction window stays open. The moderator's later re-flag to good-faith CORRUPTED then re-snapshots a depleted pool — bountyEntitlement collapses to principal-only — so the named whitehat permanently loses the entire bonus. §4's own "because" (finality is tied to value movement) is false here: value left, yet the re-flag is still honored and cannot restore the whitehat's entitlement.

// flagOutcome — re-flag is allowed while claimsStarted is false; it re-snapshots off the LIVE totalBonus
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
@> snapshotTotalBonus = totalBonus; // reads the now-zeroed totalBonus
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // = P only
// sweepUnclaimedBonus — with riskWindowStart == 0 the reserve excludes the bonus, so the WHOLE bonus sweeps
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { reserved += snapshotTotalBonus - claimedBonus; } // skipped when RWS==0
}
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // == entire bonus B
@> if (totalEligibleStake == 0 || riskWindowStart == 0) { totalBonus -= amount; } // totalBonus -> 0
// Intentionally does NOT set claimsStarted — but real accounted bonus has left the pool:
@> stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

  • The moderator flags SURVIVED on a genuinely in-scope CORRUPTED agreement with riskWindowStart == 0 — exactly the premature/typo mis-flag the re-flag correction window exists to fix (not an "evil admin", the documented correction scenario).

  • A permissionless griefer calls sweepUnclaimedBonus() in that window, draining the whole bonus to recoveryAddress before the moderator re-flags the correct good-faith CORRUPTED.

Impact:

  • The named good-faith whitehat, who performed the in-scope breach, permanently loses the entire bonus pool — it is diverted to recoveryAddress (which the sponsor controls, so the sponsor can harvest it) instead of paid as the bounty.

  • The re-flag correction feature is rendered incomplete: the moderator can restore the correct CORRUPTED outcome but cannot restore the whitehat's bonus, because value already left through a path that did not latch finality.

Proof of Concept

function test_F3_sweepPoisonsReflag_whitehatLosesBonus() external {
_stake(alice, 100e18); // principal P
_contributeBonus(sponsor, 50e18); // bonus B (owed to the eventual whitehat under CORRUPTED)
reg.setAgreementState(CORRUPTED); // in-scope breach; riskWindowStart == 0 (lazy observation)
vm.prank(moderator);
pool.flagOutcome(SURVIVED, false, address(0)); // premature/typo mis-flag (§4 correction scenario)
// permissionless griefer drains the whole bonus; claimsStarted stays false
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50e18); // entire B -> recoveryAddress
// moderator corrects to good-faith CORRUPTED — but re-snapshot sees totalBonus == 0
vm.prank(moderator);
pool.flagOutcome(CORRUPTED, true, whitehat);
assertEq(pool.bountyEntitlement(), 100e18); // P only, bonus gone
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 100e18); // whitehat gets P, NOT P + B (loses the full 50e18 bonus)
// CONTROL: direct good-faith CORRUPTED with no intermediate SURVIVED+sweep => whitehat gets P + B.
}

Recommended Mitigation

Tie finality to this value movement too: when sweepUnclaimedBonus moves accounted bonus (not merely a post-flag donation), latch claimsStarted so a later re-flag cannot silently under-pay the whitehat; equivalently, block the sweep until the correction window is closed.

uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
- // Intentionally does NOT set claimsStarted.
+ // Accounted bonus (not a post-flag donation) is leaving: latch finality so a subsequent
+ // re-flag cannot re-snapshot a depleted pool and rob the named whitehat of the bonus.
+ if (amount != 0) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);

Support

FAQs

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

Give us feedback!