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

sweepUnclaimedBonus is permissionless and, run during the moderator SURVIVED to CORRUPTED re-flag window, drains the live bonus so the good-faith CORRUPTED payout underpays the whitehat

Author Revealed upon completion

Description

When a pool resolves SURVIVED or EXPIRED, sweepUnclaimedBonus returns the bonus that is no longer owed to any staker back to the sponsor recoveryAddress. Separately, flagOutcome lets the trusted moderator correct a SURVIVED outcome to good-faith CORRUPTED while claimsStarted == false, paying the whitehat a bounty snapshotted from totalBonus. Each behavior is intended on its own.

The problem is that the two were never reconciled. sweepUnclaimedBonus is permissionless and its only state guard is outcome == SURVIVED || EXPIRED. A moderator-flagged SURVIVED on a CORRUPTED-registry pool is explicitly valid and does NOT latch claimsStarted, so the moderator's re-flag window is still open. In the riskWindowStart == 0 branch the sweep zeroes the LIVE totalBonus and deliberately leaves claimsStarted false. A subsequent, supported SURVIVED to good-faith CORRUPTED re-flag then re-snapshots snapshotTotalBonus from the already-drained totalBonus, so bountyEntitlement covers principal only and the whole bonus is permanently diverted to recoveryAddress.

function sweepUnclaimedBonus() external nonReentrant { // ConfidencePool.sol:490
@> // no access control: any address can call this
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep(); // SURVIVED window is non-final
}
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // :516 drains LIVE totalBonus
}
...
@> stakeToken.safeTransfer(recoveryAddress, amount); // :522 whole bonus to sponsor
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet(); // :336 re-flag still allowed (claimsStarted==false)
...
@> snapshotTotalBonus = totalBonus; // :367 re-reads the drained value
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // :372 underpays
}

Impact

Medium

Proof of Concept

The test drives the REAL ConfidencePool through the project's own BaseConfidencePoolTest harness (test/unit/NemesisHunt.t.sol): a pool reaches terminal CORRUPTED with riskWindowStart == 0 (nobody observes it during UNDER_ATTACK), the moderator flags SURVIVED, an attacker sweeps the bonus, and the moderator then issues the supported good-faith CORRUPTED correction. A paired control test (testDirectGoodFaithCorruptedPaysWholePool) shows the whitehat is owed the whole 150e18 pool when no sweep intervenes.

function testSweepThenReflagStripsAttackerBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_terminalCorruptedNoRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window never observed");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // step 1
assertEq(pool.claimsStarted(), false, "survived flag does not latch finality");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus(); // step 2 (permissionless)
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "whole bonus swept to sponsor recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed by the sweep");
assertEq(pool.claimsStarted(), false, "sweep left the re-flag window open");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // step 3 (supported correction)
assertEq(pool.bountyEntitlement(), 100 * ONE, "bounty missing the whole bonus pool");
uint256 before = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty(); // step 4
assertEq(token.balanceOf(attacker) - before, 100 * ONE, "attacker underpaid: bonus diverted to sponsor");
}

Output (forge test --match-contract NemesisHunt -vv, forge 1.7.1):

[PASS] testDirectGoodFaithCorruptedPaysWholePool() (gas: 576253)
[PASS] testSweepThenReflagStripsAttackerBonus() (gas: 599028)
attackerGot: 100000000000000000000
sponsorCapturedBonus: 50000000000000000000
Suite result: ok. 2 passed; 0 failed; 0 skipped

The control proves 150e18 is owed; the exploit proves the interleaved sweep leaves the whitehat with 100e18 and diverts 50e18 to the sponsor.

Recommended Mitigation

Do not let a permissionless sweep drain the bonus while the moderator re-flag window is still open. Gate the bonus-draining path on the outcome being final, so the value the CORRUPTED payout re-snapshots cannot be shrunk out from under it.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // A SURVIVED outcome on a CORRUPTED-registry pool can still be re-flagged to
+ // good-faith CORRUPTED while claimsStarted == false, and that re-flag re-snapshots
+ // the bounty from the live totalBonus. Refuse to drain the bonus until the outcome
+ // is final so a permissionless sweep cannot pre-empt a still-owed bounty.
+ if (outcome == PoolStates.Outcome.SURVIVED && !claimsStarted) {
+ revert OutcomeNotEligibleForSweep();
+ }

Alternatively, capture snapshotTotalBonus once at the first flagOutcome and have the CORRUPTED re-flag reuse that frozen value instead of re-reading a totalBonus that a later sweep can mutate (combined with reserving the bonus so the sweep cannot remove funds a re-flag would still owe).

Risk

Likelihood:

  • Any address can call sweepUnclaimedBonus the moment the moderator flags SURVIVED, because the function carries no access-control modifier (ConfidencePool.sol:490). Flagging SURVIVED on a CORRUPTED-registry pool is a documented, supported moderator action (ConfidencePool.sol:347).

  • The riskWindowStart == 0 branch is reached whenever no entrypoint observed the pool during its active-risk (UNDER_ATTACK / PROMOTION_REQUESTED) phase, since the window is sealed lazily. That is the ordinary state for a pool that takes no traffic during the risk window.

Impact:

  • The entire unclaimed bonus is transferred to recoveryAddress and removed from the live totalBonus (ConfidencePool.sol:516, :522). The subsequent good-faith CORRUPTED re-flag re-snapshots the bounty from the drained totalBonus (:367, :372), so the whitehat receives principal only. In the PoC the whitehat is paid 100e18 against 150e18 owed, with 50e18 of bonus permanently diverted to the sponsor.

  • The live totalBonus / snapshot accounting is left inconsistent: a value the CORRUPTED payout depends on is mutable by a permissionless call that runs before the payout is snapshotted.

Support

FAQs

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

Give us feedback!