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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge
12 days ago
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #4

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #4

Support

FAQs

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

Give us feedback!