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

sweepUnclaimedBonus moves accounted bonus to recovery without latching claimsStarted, letting a griefer underpay a corrected whitehat

Author Revealed upon completion

Root + Impact

Root cause: sweepUnclaimedBonus() moves accounted value out of the pool but intentionally does NOT set claimsStarted (ConfidencePool.sol#L503-507). DESIGN sec4 states the invariant it violates: "Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting," with claimsStarted as the "value-movement finality latch." The sweep lets value leave while claimsStarted stays false, so the moderator's pre-claim re-flag window stays open on top of already-moved funds.

Impact: After a legitimate SURVIVED classification of an out-of-scope breach (riskWindowStart == 0), an untrusted griefer front-runs the moderator's correction and calls sweepUnclaimedBonus(), sending the entire accounted bonus to recoveryAddress. When the moderator then legitimately corrects to good-faith CORRUPTED (naming a whitehat), the re-snapshot no longer contains the bonus, so bountyEntitlement is only the principal. The named whitehat is permanently underpaid by the whole bonus. PoC: whitehat should receive 150 (100 principal + 50 bonus) but receives 100 — 50 tokens lost to recoveryAddress.

Description

  • sweepUnclaimedBonus is callable by anyone once outcome is SURVIVED/EXPIRED (#L474). When riskWindowStart == 0, the reserve excludes the bonus (#L482-488), so the entire snapshotTotalBonus is swept (#L490-508).

  • The sweep deliberately skips claimsStarted so a 1-wei donation can't block re-flag — but this also leaves the latch open when it moves the real accounted bonus.

  • flagOutcome (#L327) allows re-flag while !claimsStarted (the documented typo-correction path, sec4). After the sweep, the correction re-snapshots snapshotTotalBonus = totalBonus, which the sweep already zeroed; bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus excludes the bonus. Value left the contract yet a corrective re-flag was honored — exactly what sec4 says must not happen. No trusted actor misbehaves: the moderator's SURVIVED-then-CORRUPTED correction is the documented flow; the untrusted sweep is the unfenced lever.

Risk

Likelihood: Medium — needs (1) a SURVIVED call while riskWindowStart == 0 and registry CORRUPTED (a realistic out-of-scope classification the moderator makes then corrects), and (2) an untrusted griefer front-running the permissionless sweepUnclaimedBonus. Both are ordinary, permissionless actions.

Impact: Medium — the named whitehat is deterministically underpaid the entire bonus, redirected to recoveryAddress. A specific honest party's owed funds are permanently misrouted, with no trusted-actor misbehavior.

Proof of Concept

test/audit/SweepBeforeReflagQ5.t.sol passes, asserting the bug reproduces:

// pre-risk: stake 100, contribute 50 bonus, riskWindowStart == 0
attackRegistry.setAgreementState(CORRUPTED); // terminal, no risk observed
vm.prank(moderator);
pool.flagOutcome(SURVIVED, false, address(0)); // legit out-of-scope classification
vm.prank(griefer);
pool.sweepUnclaimedBonus(); // untrusted: sweeps the whole 50 bonus to recovery
assertFalse(pool.claimsStarted()); // latch NOT set -> re-flag still allowed
vm.prank(moderator);
pool.flagOutcome(CORRUPTED, true, whitehat); // legit correction
vm.prank(whitehat);
pool.claimAttackerBounty(); // receives 100 (principal only), NOT 150

Logs: bonus swept to recovery = 50, whitehat bountyEntitlement = 100, whitehat received = 100.

Recommended Mitigation

Latch claimsStarted whenever sweepUnclaimedBonus transfers accounted bonus (when it decrements totalBonus), keeping the no-latch exception only for provable excess/donations:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
uint256 dec = amount <= totalBonus ? amount : totalBonus;
totalBonus -= dec;
if (dec > 0 && !claimsStarted) claimsStarted = true; // accounted value left -> lock the outcome
}

Alternatively, block sweepUnclaimedBonus from moving accounted bonus until the re-flag window closes.

Support

FAQs

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

Give us feedback!