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

Sponsor can change `recoveryAddress` after CORRUPTED outcome is flagged redirecting full pool sweep to arbitrary address

Author Revealed upon completion

Description

When the pool outcome is flagged as CORRUPTED (bad faith), the entire pool balance (staker principal + bonus) is swept to recoveryAddress via claimCorrupted(). Stakers deposit with the understanding that, on corruption, their funds flow to the published recoveryAddress. Other sponsor-controlled parameters like expiry and pool scope have one way lock mechanisms to protect staker reliance expiry locks after the first stake and scope locks when the registry leaves pre attack staging.

However, setRecoveryAddress() has no lock mechanism it can be called by the owner at any time including after the outcome has already been flagged as CORRUPTED. This allows the sponsor to redirect the full pool sweep to an arbitrary address after the outcome is determined but before claimCorrupted() is called.

This creates an inconsistency: expiry and scope are locked to protect staker expectations but recoveryAddress which controls where all staker funds go on corruption is not.

Root Cause

// src/ConfidencePool.sol — setRecoveryAddress has no outcome guard
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// @audit-no check on `outcome` — can be called post-resolution!
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
Compare with `setExpiry` which correctly locks after the first stake:
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); // @audit-protected staker reliance
}
And `setPoolScope` which correctly locks after the registry leaves pre attack staging:
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable(); // @audit-protected staker reliance
_replaceScope(accounts);
}

recoveryAddress has no equivalent protection despite controlling the destination of the entire pool on CORRUPTED.

Risk

Likelihood: Medium

  • The sponsor must be malicious (not just negligent) but has a clear financial incentive on bad faith CORRUPTED outcome where they receive the full pool

  • The attack is trivially executable a single transaction to setRecoveryAddress()before claimCorrupted() is called

  • No time pressure the window lasts from flagOutcome until claimCorrupted() is called which can be indefinite

Impact: High

  • On bad faith CORRUPTED: 100% of staker principal + bonus is redirected to the sponsor's chosen address

  • On good faith CORRUPTED: the residual after the attacker's bounty claim is redirected

  • On SURVIVED/EXPIRED: sweepUnclaimedBonus and sweepUnclaimedCorrupted send to recoveryAddress, so bonus dust and donations can be redirected

  • Stakers who verified recoveryAddress before depositing have their assumption violated after the outcome is determined

Proof of Concept

Attack flow: A malicious sponsor calls setRecoveryAddress() after the pool outcome has been flagged CORRUPTED but before claimCorrupted() is called. Since setRecoveryAddress() has no outcome state check the call succeeds and overwrites the recovery address. When claimCorrupted() is then called it sweeps 100% of the pool balance (staker principal + bonus) to the attacker's new address instead of the original recoveryAddress that stakers trusted at deposit time.

//Add this test to `test/unit/ConfidencePool.t.sol`
function testSponsorCanRedirectCorruptedSweepByChangingRecoveryAddress() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator flags bad faith CORRUPTED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Stakers expect funds to go to the original recoveryAddress
address originalRecovery = pool.recoveryAddress();
assertEq(originalRecovery, recovery);
// Sponsor changes recoveryAddress AFTER outcome is flagged
address sponsorWallet = makeAddr("sponsorWallet");
pool.setRecoveryAddress(sponsorWallet);
// claimCorrupted now sweeps everything to the sponsor's wallet, NOT the original recovery
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorWallet), 180 * ONE, "full pool redirected to sponsor");
assertEq(token.balanceOf(originalRecovery), 0, "original recovery gets nothing");
}

Recommended Mitigation

Add an outcome guard to setRecoveryAddress() so it reverts once the pool outcome has been set. This follows the same pattern already used for expiryLocked and scopeLocked sponsor controlled parameters that affect staker funds must be locked once stakers have relied on them. After the outcome is set there is no legitimate reason for the owner to change the recovery address.

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);
}

Support

FAQs

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

Give us feedback!