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

recoveryAddress is not locked after CORRUPTED resolution, allowing sweep redirection

Author Revealed upon completion

Root + Impact

Description

  • ConfidencePool locks expiry after the first stake and locks scope when the registry leaves pre-attack staging to protect staker reliance. However, it does not lock recoveryAddress after the pool is resolved to CORRUPTED. As a result, the sponsor can change the published recovery address after the worst outcome is known and redirect the full CORRUPTED sweep to a new address, violating an inconsistent privilege boundary.

  • The contract enforces one-way locks on sponsor-controlled parameters that stakers rely on, with a single exception:

    Sponsor parameter Locked after staker commitment? Mechanism
    expiry Yes expiryLocked latch on first stake
    scope Yes permanently locked once registry exits pre-attack staging
    recoveryAddress No mutable even after CORRUPTED resolution

    The CORRUPTED sweep destination is a staker-reliance parameter in the same trust surface as expiry and scope, yet it is the only one left unguarded.

// In src/ConfidencePool.sol (lines 611-618)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
@> recoveryAddress = newRecoveryAddress; // No state guard: callable even after CORRUPTED resolution
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

This finding does not rely on the sponsor being malicious in the abstract "trusted admin" sense. It is a contract-level inconsistency: the protocol's own staker-protection logic locks expiry and scope but leaves the CORRUPTED sweep destination mutable post-resolution. The issue is an incomplete invariant, not an external trust assumption.

Risk

Likelihood:

• The parameter redirection is completely unilateral, requires no external conditions, and can be executed at any time after the pool state transitions to CORRUPTED.

• The contract fails to enforce its own established trust boundaries dynamically, leaving the setter unguarded post-resolution.

Impact:

The harm is not that stakers forfeit principal in bad-faith CORRUPTED — that forfeiture is by design. The harm is that the destination of the entire swept pool can be changed after the outcome is known, breaking the neutrality stakers relied on at stake time.

• Stakers deposit trusting the published recoveryAddress (a neutral / protocol destination for forfeited funds). This neutrality is part of the pool's economic security: it ensures the sponsor has no profit motive from a corruption outcome.

• Because setRecoveryAddress has no post-resolution lock, the sponsor can redirect the full pool (100% of staked principal + bonus) to an address they control after CORRUPTED is flagged — capturing funds stakers expected to go to the neutral destination.

• This creates a direct incentive for the sponsor to welcome or collude toward a bad-faith CORRUPTED outcome, which the fixed recovery destination was meant to prevent.

• The contract already locks expiry and scope to protect exactly this kind of staker reliance; recoveryAddress is the sole exception.

Proof of Concept

function testRecoveryAddressCanBeChangedAfterCorruptedFlag() external {
// 1. Alice stakes in the pool.
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// 2. Move registry through active risk and into CORRUPTED, then moderator flags bad-faith.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Outcome is now CORRUPTED; the pool has not yet paid anyone.
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertEq(pool.claimsStarted(), false);
// 3. Sponsor (pool owner) changes recoveryAddress AFTER the outcome is flagged.
address oldRecovery = pool.recoveryAddress();
assertEq(oldRecovery, recovery);
pool.setRecoveryAddress(newRecovery);
assertEq(pool.recoveryAddress(), newRecovery);
// 4. claimCorrupted sends the full pool balance to the NEW recovery address, not the old one.
uint256 newRecoveryBefore = token.balanceOf(newRecovery);
uint256 oldRecoveryBefore = token.balanceOf(oldRecovery);
pool.claimCorrupted();
uint256 swept = token.balanceOf(newRecovery) - newRecoveryBefore;
uint256 oldSwept = token.balanceOf(oldRecovery) - oldRecoveryBefore;
assertEq(swept, 150 * ONE, "sweep should go to new recovery address");
assertEq(oldSwept, 0, "old recovery address should receive nothing");
}

**Overview:** The contract locks `expiry` after the first stake and `scope` after the registry leaves pre-attack staging, but `recoveryAddress` remains mutable after the moderator flags `CORRUPTED`. This test proves the sponsor can call `setRecoveryAddress` after the flag and redirect the full `claimCorrupted` sweep to a new address, while the original recovery address receives nothing.

**Actors:**

  • **Sponsor / pool owner**: controls `recoveryAddress` and can change it after resolution because the contract lacks a lock.

  • **Victim**: stakers who deposited into the pool relying on the published recovery address.

  • **Protocol**: `ConfidencePool`.

**Step-by-step explanation:** Alice stakes 100 tokens and Carol contributes a 50-token bonus, so the pool holds 150 tokens. The registry is advanced through the active risk window into `CORRUPTED`, and the moderator flags a bad-faith `CORRUPTED` outcome. At this point the outcome is final but no funds have moved (`claimsStarted == false`). The sponsor then calls `setRecoveryAddress(newRecovery)` — which the contract still permits post-resolution — and `claimCorrupted()` sweeps the entire 150-token pool to `newRecovery`. The assertions confirm the original recovery address receives 0, proving the full staker principal + bonus was redirected after the outcome was known. Two additional tests in `test/audit/P1H07_RecoveryAddressReroute.t.sol` cover the same root cause in the auto-CORRUPTED backstop path (`claimExpired`) and the good-faith bounty sweep path (`sweepUnclaimedCorrupted`).

Recommended Mitigation

Add a state check guard to setRecoveryAddress() so it can only be called while the pool is in the UNRESOLVED state. This brings recoveryAddress in line with the existing expiry and scope locks, ensuring staker-relevant sponsor parameters become immutable once the outcome is being resolved.

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

Support

FAQs

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

Give us feedback!