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

[M-02] setRecoveryAddress() has no post-outcome lock - sponsor can redirect CORRUPTED sweep after moderator decision

Author Revealed upon completion

Root + Impact

Description

recoveryAddress is the destination for all CORRUPTED sweeps and unclaimed bonuses. It is set at pool initialization and can be updated by the pool owner (sponsor) at any time via setRecoveryAddress(). All three sweep functions claimCorrupted(), sweepUnclaimedCorrupted(), and sweepUnclaimedBonus() read recoveryAddress live at execution time rather than from a snapshot taken at outcome resolution.

setRecoveryAddress() has no guard on outcome or claimsStarted, meaning the sponsor can change the sweep destination after the moderator has flagged the outcome and before any sweep executes:

// ConfidencePool.sol line 611-617
// @> No outcome state check - callable at any point in the pool lifecycle
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
// @> recoveryAddress updated without checking whether outcome is already set
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
// claimCorrupted() line 423 - reads live recoveryAddress, not a snapshot
// @> stakeToken.safeTransfer(recoveryAddress, toSweep)

Risk

Likelihood:

  • A sponsor who disagrees with a bad-faith CORRUPTED determination has a direct financial incentive to act. The transaction costs only a single setRecoveryAddress call before anyone sweeps.

  • The window is permissionlessly triggerable; any party can call claimCorrupted(), so the sponsor must front-run. On chains with a public mempool, this is straightforward.

Impact:

  • In the bad-faith CORRUPTED path, the sponsor can silently redirect the entire pool balance (all staker principal + bonus) to an arbitrary address after the moderator's decision, since stakers have no claim rights in this path and cannot prevent the sweep.

  • Post-resolution sweepUnclaimedBonus() in SURVIVED/EXPIRED paths is also re-routable, allowing the sponsor to redirect excess bonus that would otherwise go to the intended protocol recovery wallet.

Proof of Concept

The scenario assumes the sponsor disagrees with a bad-faith CORRUPTED outcome and wants the pool balance sent elsewhere. Because setRecoveryAddress() has no outcome check, the sponsor can update it at any time after flagOutcome() and before anyone triggers the sweep. On a chain with a public mempool the sponsor can watch for the OutcomeFlagged event and submit setRecoveryAddress() in the same or next block before any sweep transaction lands.

// Setup: pool has 10 000e18 tokens (staker principal + bonus)
// recoveryAddress is initially set to protocolTreasury
// Step 1: Moderator flags bad-faith CORRUPTED (no named attacker; full sweep to recoveryAddress)
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// pool.outcome == CORRUPTED, pool.goodFaith == false
// All 10 000e18 tokens are now destined for protocolTreasury via claimCorrupted()
// Step 2: Sponsor front-runs anyone calling claimCorrupted()
pool.setRecoveryAddress(sponsorAlternateWallet); // succeeds - no outcome check
// recoveryAddress is now sponsorAlternateWallet
// Step 3: Anyone calls claimCorrupted() (permissionless)
pool.claimCorrupted();
// stakeToken.safeTransfer(recoveryAddress, toSweep)
// → 10 000e18 tokens sent to sponsorAlternateWallet, not protocolTreasury
// Stakers lose all principal; the moderator's intended sweep destination is bypassed

Recommended Mitigation

Lock recoveryAddress once the outcome has been set by adding an outcome state guard:

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

An alternative is to snapshot recoveryAddress into a storage variable at flagOutcome() time (analogous to snapshotTotalStaked) and have all sweep functions reference the snapshot rather than the live value.

Note: docs/DESIGN.md §10 documents recoveryAddress as a sponsor trust surface. If the protocol explicitly accepts post-outcome redirection as intended behavior, this finding should be reclassified as Informational with a staker-facing disclosure that recoveryAddress is sponsor-mutable at any time.

Support

FAQs

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

Give us feedback!