FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

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!