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

Moderator Re-Flag Race Condition Allows Outcome Manipulation

Author Revealed upon completion

Description

ConfidencePool.sol allows the moderator to resolve pool outcomes via flagOutcome(). The guard at line 327 prevents re-flagging, but only after claimsStarted is set. Before that, the outcome can be overwritten indefinitely.

Vulnerability Details

// ConfidencePool.sol:327
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted)
revert OutcomeAlreadySet();

Because claimsStarted defaults to 0, the guard is inactive until explicitly set elsewhere. A moderator can flag SURVIVED, wait for stakers to submit claimSurvived() to the mempool, then front-run by re-flagging to CORRUPTED with themselves as attacker. The pool is then drained via claimAttackerBounty(), and stakers' claims revert.

Risk

Likelihood: High — A single privileged address (moderator) controls the exploitable function with no external dependencies. Stakers predictably submit claims after seeing SURVIVED, creating reliable mempool targets.

Impact: High — The entire pool (all stakes + bonus) is stolen via claimAttackerBounty(). No recovery mechanism exists. The protocol's confidence mechanism becomes the attack vector.

Proof of Concept

// Step 1: Moderator flags SURVIVED
confidencePool.flagOutcome(poolId, PoolStates.Outcome.SURVIVED, true, address(0));
// Outcome: SURVIVED | claimsStarted: 0 | Guard: inactive
// Step 2: Staker sees SURVIVED, submits claimSurvived() to mempool
// Step 3: Moderator front-runs — re-flag to CORRUPTED succeeds because guard is inactive
confidencePool.flagOutcome(poolId, PoolStates.Outcome.CORRUPTED, true, moderatorAddr);
// Outcome: CORRUPTED | attacker: moderatorAddr
// Step 4: Moderator drains pool
confidencePool.claimAttackerBounty(poolId);
// Pool balance → moderatorAddr
// Step 5: Staker's claimSurvived() lands → reverts (outcome no longer SURVIVED)

Recommended Mitigation

Remove the claimsStarted dependency so the outcome becomes final on first write:

- if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted)
- revert OutcomeAlreadySet();
+ if (outcome != PoolStates.Outcome.UNRESOLVED)
+ revert OutcomeAlreadySet();

Why this works: After the first flagOutcome() call sets a non-UNRESOLVED value, any subsequent call reverts. This closes the re-flag window entirely. No downstream functions (claimSurvived, claimCorrupted, claimAttackerBounty) depend on claimsStarted for correctness — they check pool.state.outcome directly. The fix also saves one SLOAD in gas.

If outcome mutability is required, add a mandatory cooldown instead:

if (outcome != PoolStates.Outcome.UNRESOLVED && block.timestamp < lastFlagTime + FLAG_COOLDOWN)
revert OutcomeChangeTooSoon();

References

  • src/ConfidencePool.sol#L327 — re-flag guard

  • src/ConfidencePool.sol#L376claimAttackerBounty()










This gives stakers a guaranteed window to claim before any outcome change can take effect.

Recommended Mitigation

Remove the && claimsStarted condition so the outcome becomes final on first set, regardless of claim status.

Support

FAQs

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

Give us feedback!