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

Pre claim bonus sweep breaks the documented CORRUPTED correction window

Author Revealed upon completion

Root + impact

Description

An arbitrary caller can invoke sweepUnclaimedBonus() after the moderator has flagged SURVIVED on a terminal-CORRUPTED agreement but before the moderator’s documented correction window closes. Because the sweep transfers bonus out without setting claimsStarted, a later corrected good-faith CORRUPTED re-flag snapshots only the shrunken live pool, and the eventually named whitehat permanently loses the swept bonus to recoveryAddress.

Vulnerability details

The bug is that sweepUnclaimedBonus() can remove bonus value while the code still advertises an open re-flag window, but flagOutcome() later re-snapshots only the reduced live accounting when the moderator corrects to good-faith CORRUPTED. This directly contradicts the repository’s own correction-window and bounty guarantees: DESIGN.md says once value has left the contract a corrective re-flag cannot be honored and describes claimsStarted as the value-movement finality latch, while protocol-readme.md and DESIGN.md §12 say the named good-faith attacker is entitled to the entire pool for the claim window and the full pool is reserved for the named attacker.

function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
// docs/DESIGN.md §4
- A claim is the distribution-locking event. Once value has left the contract, a corrective
re-flag cannot be honored without breaking balance accounting.
- `claimsStarted` is a value-movement finality latch

The issue is reachable only under the following conditions:

  1. The live registry must already be terminal CORRUPTED.

  2. No active-risk state must have been observed locally, so riskWindowStart == 0.

  3. The moderator must initially flag SURVIVED, then later correct to good-faith CORRUPTED.

  4. The pool must still hold a positive bonus when the initial SURVIVED flag is made.

The exploit path is:

  1. Stakers and a bonus contributor fund a pool.

  2. The agreement reaches terminal CORRUPTED, but the pool never observed UNDER_ATTACK / PROMOTION_REQUESTED, so riskWindowStart remains zero.

  3. The moderator uses the documented pre-claim correction window and initially flags SURVIVED, for example while correcting an out-of-scope / in-scope judgment or fixing a mistaken decision.

  4. Any arbitrary caller invokes sweepUnclaimedBonus(). Because riskWindowStart == 0, the whole bonus is treated as unowed under the initial SURVIVED branch, transferred to recoveryAddress, and removed from live totalBonus.

  5. claimsStarted remains false, so the moderator can still re-flag the pool to good-faith CORRUPTED.

  6. The corrected flagOutcome() call snapshots snapshotTotalBonus == 0, so bountyEntitlement becomes stake-only instead of stake plus bonus.

  7. The eventually named whitehat permanently loses the swept bonus even though the correction window was still open when the sweep happened.

The regression tests already prove the developers were aware of the ordering, but that does not invalidate the finding. It shows the code currently prefers “bonus already swept stays swept” over the stronger documented guarantees that the re-flag window remains meaningful until finality and that the named good-faith attacker is reserved the entire pool.

Risk

Likelihood:

  • Reason 1 // The underpayment path appears once the moderator has already flagged SURVIVED on a terminal-CORRUPTED agreement while claimsStarted remains false and the correction window is still open.

  • Reason 2 // The bonus loss is realized on the first intervening sweepUnclaimedBonus() call, because that call moves value out and reduces live totalBonus before the corrected good-faith CORRUPTED snapshot is taken.


Impact

An arbitrary caller permanently diverts bonus value away from the eventually named good-faith attacker and into recoveryAddress while the moderator’s documented correction window is still open. The corrected attacker receives only the shrunken post-sweep pool instead of the full good-faith CORRUPTED pool, so the protocol can underpay its named whitehat even though no claim finality was supposed to have occurred yet.

POC

The PoC already exists in test/unit/SweepUnclaimedBonus.t.sol.

Full PoC test:

function testPreClaimSweepLeavesReflagOpenButShrinksCorrectedAttackerBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window starts open");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertFalse(pool.claimsStarted(), "sweep leaves re-flag window open");
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus already moved out");
assertEq(pool.totalBonus(), 0, "live bonus accounting is reduced before correction");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalStaked(), 100 * ONE, "stake re-snapshotted");
assertEq(pool.snapshotTotalBonus(), 0, "swept bonus is excluded from corrected snapshot");
assertEq(pool.bountyEntitlement(), 100 * ONE, "attacker no longer receives the swept bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE, "whitehat only receives stake-backed bounty");
}

Run command:

forge test --offline --match-test testPreClaimSweepLeavesReflagOpenButShrinksCorrectedAttackerBounty

Observed output:

No files changed, compilation skipped
Ran 1 test for test/unit/SweepUnclaimedBonus.t.sol:SweepUnclaimedBonusTest
[PASS] testPreClaimSweepLeavesReflagOpenButShrinksCorrectedAttackerBounty() (gas: 593020)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.64ms (1.25ms CPU time)
Ran 1 test suite in 12.87ms (7.64ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Mitigation

Make sweepUnclaimedBonus() correction-final by setting claimsStarted, or forbid bonus sweeping while a later good-faith CORRUPTED correction remains possible.

Support

FAQs

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

Give us feedback!