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

Sponsor can change recoveryAddress after stakers are locked, redirecting CORRUPTED funds

Author Revealed upon completion

## Root + Impact
`setRecoveryAddress()` has no state restriction — the sponsor can change `recoveryAddress` after `UNDER_ATTACK` when staker withdrawals are permanently disabled, redirecting all CORRUPTED sweep funds to an arbitrary address.
## Description
`setRecoveryAddress()` in `ConfidencePool.sol` (line 611) only checks for `address(0)`:
```solidity
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
```
This is inconsistent with the protection pattern applied to other sponsor-controlled parameters:
- `expiry` is locked by `expiryLocked` on first stake (DESIGN.md §10)
- `scope` is locked by `scopeLocked` when registry leaves pre-attack staging (DESIGN.md §8)
- `recoveryAddress` has NO lock mechanism
Stakers verify `recoveryAddress` at deposit time as part of their trust assessment. After `UNDER_ATTACK`, the `riskWindowStart != 0` latch permanently disables `withdraw()`, so stakers cannot exit even if they detect the change.
## Risk
**Likelihood:**
- The sponsor (pool owner) can call `setRecoveryAddress()` at any time including after stakers are locked
- The sponsor can monitor the registry on-chain and front-run the CORRUPTED transition
**Impact:**
- **Certain, not probabilistic**: Once the sponsor calls `setRecoveryAddress()` before the CORRUPTED outcome is finalized, the redirection is guaranteed to succeed — there is no additional condition, race, or permission check standing in the way.
- **Total loss of pool funds**: `claimCorrupted()` sweeps `snapshotTotalStaked + snapshotTotalBonus` — i.e. 100% of staker principal and contributed bonus — to whatever `recoveryAddress` currently holds. The PoC confirms 180 ONE (all staked + bonus funds) redirected, with the original recovery address receiving 0.
- **Breaks the core trust guarantee of the protocol**: `recoveryAddress` is the mechanism stakers rely on when they deposit — DESIGN.md itself treats it as security-critical enough to lock `expiry` (§10) and `scope` (§8) once risk materializes. Leaving `recoveryAddress` unlocked defeats the purpose of locking the other two; a malicious or compromised sponsor retains a standing "steal everything" lever throughout the entire UNDER_ATTACK window.
- **No detection/prevention mechanism for stakers**: stakers cannot see or block this on-chain change before CORRUPTED is flagged; by the time it's observable, funds may already be irrecoverable.
## Proof of Concept
```solidity
function testSponsorChangesRecoveryAfterLock() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
address sponsorWallet = makeAddr("sponsorWallet");
pool.setRecoveryAddress(sponsorWallet);
assertEq(pool.recoveryAddress(), sponsorWallet);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 sponsorBefore = token.balanceOf(sponsorWallet);
pool.claimCorrupted();
uint256 sponsorReceived = token.balanceOf(sponsorWallet) - sponsorBefore;
assertEq(sponsorReceived, 180 * ONE, "sponsor stole all funds");
emit log_named_uint("sponsor_received", sponsorReceived);
emit log_named_uint("original_recovery_received", token.balanceOf(recovery));
}
```
Result: PASS — sponsor_received: 180000000000000000000 (all funds), original_recovery_received: 0
## Recommended Mitigation
Lock `recoveryAddress` once risk materializes, consistent with existing latch patterns:
```diff
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (riskWindowStart != 0) revert RecoveryAddressLocked();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
```

function testSponsorChangesRecoveryAfterLock() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
address sponsorWallet = makeAddr("sponsorWallet");
pool.setRecoveryAddress(sponsorWallet);
assertEq(pool.recoveryAddress(), sponsorWallet);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 sponsorBefore = token.balanceOf(sponsorWallet);
pool.claimCorrupted();
uint256 sponsorReceived = token.balanceOf(sponsorWallet) - sponsorBefore;
assertEq(sponsorReceived, 180 * ONE, "sponsor stole all funds");
}

function testSponsorChangesRecoveryAfterLock() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
address sponsorWallet = makeAddr("sponsorWallet");
pool.setRecoveryAddress(sponsorWallet);
assertEq(pool.recoveryAddress(), sponsorWallet);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 sponsorBefore = token.balanceOf(sponsorWallet);
pool.claimCorrupted();
uint256 sponsorReceived = token.balanceOf(sponsorWallet) - sponsorBefore;
assertEq(sponsorReceived, 180 * ONE, "sponsor stole all funds");
}

Support

FAQs

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

Give us feedback!