FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!