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

`sweepUnclaimedBonus()` drains the whole bonus pool pre-claim, so a later moderator re-flag to good-faith `CORRUPTED` silently shorts the named whitehat's bounty

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: When the moderator flags good-faith CORRUPTED and names a whitehat, bountyEntitlement is set to the entire pool (snapshotTotalStaked + snapshotTotalBonus), and the named whitehat is entitled to claim all of it. The re-flag correction window is deliberately kept open until the first real claim — sweepUnclaimedBonus() intentionally does NOT set claimsStarted so a stray sweep cannot prematurely close that window.

  • Issue: When riskWindowStart == 0, sweepUnclaimedBonus() treats the ENTIRE bonus pool as unowed and sweeps it to recoveryAddress while leaving claimsStarted false. If this runs after a SURVIVED flag but before the moderator corrects the outcome to good-faith CORRUPTED, the re-flag succeeds (no revert) but recomputes bountyEntitlement from the now-drained totalBonus — permanently shorting the whitehat by the swept amount, which is stuck at the sponsor-controlled recoveryAddress.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
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, so the moderator's re-flag window stays open...
//@> ...but the whole bonus already left, so the later good-faith CORRUPTED re-flag is silently shorted
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

  • The registry reaches terminal CORRUPTED without any pool interaction ever observing an active-risk state, so riskWindowStart stays 0 (DESIGN.md §5 confirms this state is reachable).

  • The moderator's first flag is SURVIVED (an out-of-scope-breach judgment) and is later corrected to good-faith CORRUPTED — the exact re-flag correction workflow the contract supports.

  • Any address calls the permissionless sweepUnclaimedBonus() in the window between the two flags; the sponsor who controls recoveryAddress gains the swept amount and needs no privilege to trigger it.

Impact:

  • The named whitehat receives the pool minus the swept bonus (e.g. 100 instead of 120) — a direct, permanent shortfall of their good-faith bounty.

  • The swept bonus is stuck at recoveryAddress; claimCorrupted() reverts NothingToSweep(), so it is unrecoverable, not merely delayed.

Proof of Concept

This PoC contains two tests with identical setup — 100 staked, 20 bonus, riskWindowStart == 0, registry reads CORRUPTED. The only difference is that the exploit test inserts an interim SURVIVED flag followed by a permissionless sweepUnclaimedBonus() before the good-faith CORRUPTED flag; the control does not. With the sweep, the whitehat's bounty is computed as 100; without it, 120. This isolates the sweep-then-reflag sequence as the sole cause of the 20-token shortfall. Both tests pass (2 passed; 0 failed).

// test/exploit/Hunt.reflagDrain.t.sol — both tests PASS.
// Identical setup (stake 100, bonus 20, riskWindowStart==0, registry CORRUPTED);
// the ONLY difference is the interim SURVIVED+sweep. Control pays 120, exploit pays 100.
function test_SweepUnclaimedBonus_ThenReflagToCorrupted_ShortsAttackerBounty() public {
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("sponsor"), 20 * ONE);
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(makeAddr("sweeper"));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 20 * ONE);
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE); // should be 120
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE); // shorted by 20
vm.expectRevert();
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 20 * ONE); // permanently diverted
}
function test_Control_DirectGoodFaithCorrupted_PaysFullBounty() public {
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("sponsor"), 20 * ONE);
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // direct, no sweep
assertEq(pool.bountyEntitlement(), 120 * ONE);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 120 * ONE); // full, correct bounty
}

Recommended Mitigation

The fix blocks sweepUnclaimedBonus() only in the exact state where the outcome is still correctable toward good-faith CORRUPTED — a SURVIVED outcome with no observed risk window while the live registry still reads CORRUPTED. This prevents the bonus from leaving before the moderator's re-flag sizes bountyEntitlement, while leaving all normal dust/excess sweeps untouched.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Block the sweep while the SURVIVED outcome is still correctable to good-faith CORRUPTED,
+ // so the bonus cannot leave before the moderator's re-flag sizes bountyEntitlement.
+ // Requires: error SweepBlockedPendingPossibleCorrection();
+ if (outcome == PoolStates.Outcome.SURVIVED && riskWindowStart == 0) {
+ if (_getAgreementState() == IAttackRegistry.ContractState.CORRUPTED) {
+ revert SweepBlockedPendingPossibleCorrection();
+ }
+ }
uint256 reserved;
Do not instead set claimsStarted in the sweep — that omission is deliberate anti-grief design (1-wei-donation griefing of the re-flag window).

Support

FAQs

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

Give us feedback!