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

Bonus swept to recovery before moderator CORRUPTED correction, underpaying whitehat bounty

Author Revealed upon completion

Bonus swept to recovery before moderator CORRUPTED correction, underpaying whitehat bounty

Description

DESIGN.md §4 allows the moderator to re-flag a pool's outcome pre-claim to correct a typo'd flag (e.g. SURVIVED → CORRUPTED). DESIGN.md §12 promises the named whitehat "the entire pool is the named attacker's bounty" — bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus.

When riskWindowStart == 0 (no pool interaction observed the registry during active-risk states), sweepUnclaimedBonus treats the entire snapshotTotalBonus as unowed by stakers and sweeps it to recoveryAddress (DESIGN.md §5: "no observable risk → bonus sweeps to recoveryAddress"). The sweep does not set claimsStarted (lines 503-505), so the moderator's re-flag window stays open. A subsequent re-flag to good-faith CORRUPTED re-snapshots snapshotTotalBonus from the now-depleted live totalBonus = 0 (line 358), setting bountyEntitlement = snapshotTotalStaked + 0. The whitehat receives only principal; the bonus is permanently gone to the sponsor's recoveryAddress.

function sweepUnclaimedBonus() external nonReentrant {
// ...
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
//@> if (riskWindowStart != 0) { // line 485: bonus only reserved when risk window opened
//@> reserved += snapshotTotalBonus - claimedBonus;
//@> }
}
// ...
//@> if (totalEligibleStake == 0 || riskWindowStart == 0) { // line 499: bonus decremented from live totalBonus
//@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. // line 503-505
stakeToken.safeTransfer(recoveryAddress, amount); // line 506: bonus leaves the pool
}
function flagOutcome(...) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet(); // line 327: re-flag still allowed
// ...
//@> snapshotTotalBonus = totalBonus; // line 358: re-snapshot reads depleted totalBonus = 0
//@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // line 362: attacker gets stake only
}

Risk

Likelihood:

  • The sponsor (who controls recoveryAddress) has direct economic incentive to preserve riskWindowStart == 0: calling pokeRiskWindow() or any pool function during active-risk states would seal the window and forfeit the sweep path. No protocol-level keeper automatically calls it.

  • Whitehats audit the agreement's in-scope contracts, not the ConfidencePool clone. They have no reason to interact with the pool before being named attacker — and they can't know they'll be named until after the moderator validates their disclosure and flags CORRUPTED.

  • Stakers' default mode is passive: they deposit pre-attack and wait for resolution. No pool interaction is required during the active-risk interval, so "no staker poked for the entire UNDER_ATTACK period" is a plausible operating condition, not a pathological edge.

  • The moderator correction path is explicitly sanctioned by DESIGN.md §4 ("re-flag pre-claim so the moderator can fix a typo'd outcome"). A normal operational flow — moderator optimistically flags SURVIVED before a whitehat disclosure arrives, then corrects to CORRUPTED — is not a malicious moderator, just timing.

Impact:

  • The whitehat attacker's bounty is reduced from snapshotTotalStaked + snapshotTotalBonus to snapshotTotalStaked only, permanently losing the bonus pool to the sponsor's recoveryAddress. This violates the DESIGN.md §12 bounty promise: "the entire pool is the named attacker's bounty".

  • The sponsor captures the swept bonus directly via recoveryAddress, which they can redirect at any time via setRecoveryAddress (no state gate). This creates a direct economic incentive for the sponsor to preserve riskWindowStart == 0 and front-run the moderator's correction.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PocM01 is BaseConfidencePoolTest {
/// @dev riskWindowStart==0 + SURVIVED flag + sweep + CORRUPTED re-flag
/// => attacker loses bonus to recoveryAddress
function testM01BonusSweptBeforeCorruptedReflag() external {
uint256 stake = 100 * ONE;
uint256 bonus = 50 * ONE;
_stake(alice, stake);
_contributeBonus(carol, bonus);
// Registry is CORRUPTED but no pool interaction happened during active-risk states
// => riskWindowStart == 0 (the exploitable precondition)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Moderator flags SURVIVED (out-of-scope breach judgement, allowed per line 338)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), bonus, "bonus snapshotted at flag time");
// Sponsor sweeps the unreserved bonus to recoveryAddress
// claimsStarted stays false per lines 503-505 => re-flag window still open
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus, "bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "totalBonus decremented");
// Moderator corrects to CORRUPTED goodFaith (re-flag allowed since !claimsStarted)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Re-snapshot reads depleted totalBonus=0 => bountyEntitlement excludes bonus
assertEq(pool.snapshotTotalBonus(), 0, "re-snapshot sees 0 bonus");
assertEq(pool.bountyEntitlement(), stake, "attacker gets principal only");
assertEq(pool.bountyEntitlement(), stake, "expected full pool (stake+bonus)=150e18");
// Attacker claims only principal — bonus permanently lost to recoveryAddress
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), stake, "attacker underpaid: 100 instead of 150");
}
}

Run: forge test --match-test testM01BonusSweptBeforeCorruptedReflag -vvv

Recommended Mitigation

function sweepUnclaimedBonus() external nonReentrant {
// ...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
+ reserved += snapshotTotalBonus - claimedBonus;
}
// ...
- if (totalEligibleStake == 0 || riskWindowStart == 0) {
+ if (totalEligibleStake == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

Always reserving snapshotTotalBonus for live stakers ensures the bonus cannot be swept before the re-flag window closes (it only opens when stakers exist). Donations/dust above the reserve remain sweepable. After claimsStarted (first genuine claim), the re-flag window closes and residual bonus becomes sweepable normally. No new DoS, reentrancy, or invariant violations — the sweep still works for donations above the reserve.

Support

FAQs

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

Give us feedback!