FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Bonus can be swept to `recoveryAddress` mid pre-claim reflag window, causing good-faith CORRUPTED `bountyEntitlement` to fall short of the documented full-pool guarantee

Author Revealed upon completion

Root + Impact

Description

Per line 28, a good-faith CORRUPTED outcome entitles the named attacker to the
entire pool (bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus).
flagOutcome is re-flaggable pre-claim (line 322) so the moderator can correct
a mistaken outcome before distribution — this window is gated on claimsStarted,
not on outcome == UNRESOLVED.

The issue: while riskWindowStart == 0, sweepUnclaimedBonus() reserves only
totalEligibleStake, sweeps the full bonus balance to recoveryAddress, and
deliberately does not set claimsStarted. So if the moderator flags SURVIVED
first (valid even when the live registry reads CORRUPTED), anyone can sweep the
bonus before any claim, then the moderator corrects to good-faith CORRUPTED —
the second flagOutcome snapshots totalBonus after it was already reduced.

// sweepUnclaimedBonus() — src/ConfidencePool.sol:474
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus; // bonus unreserved when riskWindowStart == 0
}
}
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);
// flagOutcome() — src/ConfidencePool.sol:354
@> snapshotTotalBonus = totalBonus; // reflects the post-sweep, reduced value
...
bountyEntitlement = willBeGoodFaithCorrupted
@> ? snapshotTotalStaked + snapshotTotalBonus // excludes swept bonus
: 0;

claimsStarted protects the distribution, but not the accounting inputs
(totalBonus) feeding the next snapshot — a value-movement event can alter
those inputs inside the window the moderator is meant to still control.

Risk

Likelihood:

  • Occurs whenever a moderator's first flag is SURVIVED on a CORRUPTED registry
    (e.g. believing the breach out-of-scope, or flagging before full review) —
    a normal, expected moderator workflow, not an edge case.

  • Permissionless: any address can trigger the sweep once the pool is in a
    sweep-eligible outcome with riskWindowStart == 0.

  • Widens naturally where riskWindowStart was never latched — a scenario the
    design doc itself acknowledges as real and accepted (§5).

Impact:

  • Breaks the documented "entire pool" invariant for good-faith CORRUPTED
    (§12) — attacker entitlement is silently reduced by whatever bonus swept.

  • Swept bonus routes to recoveryAddress permanently, with no recovery path
    for the moderator or attacker.

  • Undermines the reflag window's purpose (line 322): meant to let the
    moderator fix the outcome before value moves, but value can move without
    tripping the claimsStarted guard meant to be the sole finality signal.

Proof of Concept

This sequence simulates a pool where the registry reports CORRUPTED but the
moderator's first flag is SURVIVED. Between that flag and the corrective
re-flag, an unprivileged caller sweeps the bonus without setting
claimsStarted. When the moderator then corrects to good-faith CORRUPTED, the
new snapshot reflects the already-reduced totalBonus, so the attacker's
bounty comes up short by exactly the swept amount — demonstrating the
entitlement no longer matches the "entire pool" guarantee.

// registry == CORRUPTED, riskWindowStart == 0 (no prior active-risk observation)
pool.flagOutcome(Outcome.SURVIVED, address(0));
// outcome = SURVIVED, claimsStarted = false, totalBonus = B
pool.sweepUnclaimedBonus();
// riskWindowStart == 0 -> bonus not reserved -> B sent to recoveryAddress
// totalBonus = 0, claimsStarted still false
pool.flagOutcome(Outcome.CORRUPTED, attacker);
// snapshotTotalBonus = 0 (post-sweep)
// bountyEntitlement = snapshotTotalStaked + 0
// -> should have included B per §12
pool.claimAttackerBounty();
// receives principal only; B is stuck at recoveryAddress, unrecoverable

Recommended Mitigation

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Block sweeps while outcome is still correctable, so a reflag can't
+ // snapshot a bonus figure altered mid-window.
+ if (claimsStarted == false && outcome == PoolStates.Outcome.SURVIVED) {
+ revert SweepNotAllowedDuringReflagWindow();
+ }
...
}

Alternative: have sweepUnclaimedBonus() set claimsStarted = true whenever
it moves nonzero bonus value out of the pool — treating the sweep as the same
finality-latching event a claim is, consistent with §4's rule that value
movement, not caller identity, closes the correction window.

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.

Appeal created

Hawk 1 Submitter
2 days ago

Support

FAQs

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

Give us feedback!