FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

Permissionless claimCorrupted latches finality on bad-faith CORRUPTED, letting anyone foreclose the moderator's correction window and sweep stakers' principal to recovery

Author Revealed upon completion

Root + Impact

Root cause: claimCorrupted has no access control and, for bad-faith CORRUPTED, skips the bounty gate; it sweeps the whole pool to recoveryAddress and sets claimsStarted = true the instant a bad-faith CORRUPTED is flagged.

Impact: any unprivileged caller (or the profit-motivated recoveryAddress sponsor) can front-run the moderator's documented correction, permanently locking the wrong outcome and diverting stakers' principal to recoveryAddress.

Description

  • Normal behavior: flagOutcome may re-flag (correct) an outcome while claimsStarted == false (DESIGN.md §4). §4 justifies the "first claim closes the window" latch with "a staker claiming first is exercising a correct outcome."

  • The issue: claimCorrupted is permissionless (nonReentrant only) and for bad-faith skips the MustClaimBountyFirst gate. It sweeps the full balance to recoveryAddress and latches claimsStarted. §4's justification does not hold here — the caller holds no stake, gains nothing directly, and moves other people's principal to a third party while destroying an in-progress legitimate correction (to SURVIVED, which would refund stakers, or to good-faith CORRUPTED, which would pay a whitehat).

// src/ConfidencePool.sol — claimCorrupted (no access modifier)
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst(); // bad-faith SKIPS this gate
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
...
@> if (!claimsStarted) claimsStarted = true; // permissionless caller latches finality -> re-flag foreclosed
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

  • The moderator flags bad-faith CORRUPTED and then intends to correct it — to SURVIVED for an out-of-scope breach (refunding stakers), or to good-faith CORRUPTED to name a whitehat.

  • An unprivileged actor — or the recoveryAddress sponsor, who profits from the sweep — front-runs that correction transaction with claimCorrupted the moment the bad-faith flag lands, on an L2 mempool.

Impact:

  • When the intended correction was SURVIVED, all stakers lose their entire principal to recoveryAddress instead of being refunded.

  • The moderator's documented corrective authority is permanently neutralized by an arbitrary caller.

Proof of Concept

Full test in test/unit/ConfidencePool.claimCorruptedGrief.t.sol (2 tests pass; forge test --match-contract ClaimCorruptedGrief -vv):

function test_griefer_forecloses_moderator_correction_to_survived() public {
_stakeAndCorruptWithRiskWindow(); // alice stakes 100; risk window observed; registry -> CORRUPTED
// Moderator mis-flags bad-faith CORRUPTED (will realize it was out-of-scope -> intends SURVIVED).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(pool.claimsStarted(), false);
// Unprivileged griefer front-runs the correction.
vm.prank(bob);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), PRINCIPAL);
assertEq(pool.claimsStarted(), true);
// Moderator's documented correction is now impossible.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// alice, who should have been refunded, has nothing and cannot claim.
assertEq(token.balanceOf(alice), 0);
}

Recommended Mitigation

Give the moderator a bounded correction window before a permissionless bad-faith sweep can foreclose it, anchored to the flag block (NOT outcomeFlaggedAt, which is riskWindowEnd and can predate the flag):

+ uint32 internal _badFaithFlaggedAt; // set to block.timestamp when bad-faith CORRUPTED is flagged
...
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
+ if (!goodFaith && block.timestamp < _badFaithFlaggedAt + MODERATOR_CORRECTION_GRACE) {
+ revert CorrectionWindowOpen();
+ }
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();

Alternative: restrict the bad-faith claimCorrupted trigger to the moderator or recoveryAddress during the grace, keeping it permissionless afterward so a stuck pool stays recoverable.

Support

FAQs

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

Give us feedback!