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

Good-faith CORRUPTED whitehat bounty can be permanently stripped to `recoveryAddress`

Author Revealed upon completion

Root + Impact

sweepUnclaimedBonus is the only value-moving path that does not set the claimsStarted finality latch, and in the riskWindowStart == 0 case it moves the entire bonus (not just dust). This will cause a total loss of the bonus portion of the bounty for the named good-faith attacker (whitehat), as any caller will sweep the bonus to recoveryAddress in the still-open window between a moderator's initial flagOutcome(SURVIVED) and their documented flagOutcome(CORRUPTED, true, attacker) correction, after which the re-flag re-snapshots a now-zero totalBonus, so bountyEntitlement collapses to principal-only and the whitehat can never recover the bonus.

the choice to exempt sweepUnclaimedBonus from the claimsStarted latch is a mistake, because it is the only path where real funds leave the pool while the moderator's re-flag window stays open — and when riskWindowStart == 0 the funds that leave are the whole bonus, not the donations/dust the exemption was designed for.

In sweepUnclaimedBonus, with riskWindowStart == 0 the bonus is unreserved, swept out, and totalBonus is zeroed, but claimsStarted is deliberately left unset:

// Root cause in the codebase with @> marks to highlight the relevant section
File: src/ConfidencePool.sol
482: uint256 reserved;
483: if (totalEligibleStake != 0) {
484: reserved = totalEligibleStake;
485: if (riskWindowStart != 0) {
486: reserved += snapshotTotalBonus - claimedBonus;
487: }
488: }
...
499: if (totalEligibleStake == 0 || riskWindowStart == 0) {
500: totalBonus -= amount <= totalBonus ? amount : totalBonus;
501: }
502:
503: // Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
504: // wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
505: // documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
506: stakeToken.safeTransfer(recoveryAddress, amount);

The re-flag guard still permits the correction because claimsStarted is false:

File: src/ConfidencePool.sol
327: if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

and the re-flag re-snapshots the now-zeroed totalBonus, so the whole-pool bounty shrinks to principal only:

File: src/ConfidencePool.sol
357: snapshotTotalStaked = totalEligibleStake;
358: snapshotTotalBonus = totalBonus; // == 0 after the sweep
...
361: corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
362: bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // == principal only

The enabling precondition (riskWindowStart == 0 while the registry is CORRUPTED) is itself a documented, reachable state (DESIGN.md), and flagOutcome(SURVIVED) on a CORRUPTED registry is an intended, tested action (test/unit/ConfidencePool.scope.t.sol:177).

Attack Path

  1. The registry reaches CORRUPTED (e.g. …→UNDER_ATTACK→markCorrupted) while no pool interaction observes the active-risk interval, so riskWindowStart is never sealed (riskWindowStart == 0). Pool holds principal P + bonus B.

  2. Moderator calls flagOutcome(SURVIVED, false, 0), valid, since SURVIVED accepts a CORRUPTED registry (an initial "breach was out-of-scope" judgement). claimsStarted stays false; snapshotTotalBonus = B.

  3. Any address (e.g. the sponsor, who controls recoveryAddress and can front-run) calls sweepUnclaimedBonus(). Because riskWindowStart == 0, only principal is reserved, so the full B is transferred to recoveryAddress and totalBonus is zeroed. claimsStarted remains false.

  4. Moderator corrects the scope judgement (the flow DESIGN.md §4 exists for) and calls flagOutcome(CORRUPTED, true, attacker). The guard passes; re-snapshot captures totalBonus == 0, so bountyEntitlement = P.

  5. Whitehat (attacker) calls claimAttackerBounty() and receives only P. B is permanently stranded at recoveryAddress.
    Impact:

  • Impact 1

The named good-faith attacker (whitehat) suffers an approximate loss of B — the entire bonus pool (100% of the bonus portion of their intended P + B bounty). The sponsor's recoveryAddress gains B, a party designed to receive nothing on a good-faith CORRUPTED outcome. The loss is permanent and unrecoverable.

Proof of Concept

Drop into test/unit/PoCAudit.t.sol, extending the repo's BaseConfidencePoolTest:

// Control: a straight good-faith CORRUPTED (no sweep) pays the whitehat the WHOLE pool.
function test_control_goodFaithCorruptedPaysWholePool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 before = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - before, 130 * ONE, "attacker gets whole pool");
}
// Attack: SURVIVED -> sweep -> good-faith CORRUPTED correction => whitehat shorted the bonus.
function test_poc_bonusMisroutedBySweepBetweenReflag() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
vm.prank(moderator); // 1) initial SURVIVED (out-of-scope judgement)
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave); // 2) permissionless sweep of the whole bonus
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 30 * ONE, "bonus swept to recovery");
assertFalse(pool.claimsStarted(), "sweep does NOT lock re-flag");
vm.prank(moderator); // 3) correction to good-faith CORRUPTED
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker); // 4) whitehat claims principal only
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, 100 * ONE, "shortchanged: 100 not 130");
assertEq(token.balanceOf(recovery) - recoveryBefore, 30 * ONE, "bonus stuck at recovery");
}

Recommended Mitigation

  1. (Recommended) In sweepUnclaimedBonus, set claimsStarted = true when it actually sweeps bonus (the riskWindowStart == 0 || totalEligibleStake == 0 branch). This closes the re-flag window precisely when real value has left, without regressing the 1-wei-donation griefing concern the exemption was added for (a donation does not enter that branch with riskWindowStart != 0 and stakers present).

  2. Reserve snapshotTotalBonus - claimedBonus unconditionally (drop the riskWindowStart != 0 guard at line 485) while a CORRUPTED re-flag is still reachable, so the bonus can't be swept before finality.

  3. Block a good-faith CORRUPTED re-flag once any bonus has been swept.

Support

FAQs

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

Give us feedback!