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

Pool Owner Can Redirect ​recoveryAddress​ After Outcome Is Set, Diverting CORRUPTED Sweep Funds

Author Revealed upon completion

Root + Impact

Description

  • setRecoveryAddress() is gated only by onlyOwner — there is no check on the current outcome state. This means the pool owner (sponsor) can change the destination for CORRUPTED sweeps even after flagOutcome has been called.

  • If the moderator flags CORRUPTED (bad-faith), all funds sweep to recoveryAddress via claimCorrupted(). The owner can front-run the sweep by changing recoveryAddress to an address they control, effectively stealing staker principal.

// ConfidencePool.sol
@> function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
@> // ← no outcome check — allowed at any pool lifecycle stage
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
function claimCorrupted() external nonReentrant {
// ...
uint256 toSweep = stakeToken.balanceOf(address(this));
@> stakeToken.safeTransfer(recoveryAddress, toSweep); // ← uses live recoveryAddress
}

Risk

Likelihood:

  • This occurs when the pool owner acts maliciously after the moderator flags a CORRUPTED outcome. The owner always has the technical ability to call setRecoveryAddress between flagOutcome and the sweep.

  • The two-step ownership transfer (Ownable2Step) means only the confirmed owner can execute this, but there is no timelock.

Impact:

  • Staker principal + bonus is redirected to an attacker-controlled address instead of the legitimate recovery address.

  • Stakers have no on-chain recourse once the sweep executes.

Proof of Concept

function test_ownerCanChangeRecoveryAfterOutcome() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Owner changes recovery address after outcome is set
address newRecovery = makeAddr("newRecovery");
pool.setRecoveryAddress(newRecovery);
// Sweep goes to NEW recovery
pool.claimCorrupted();
assertGt(token.balanceOf(newRecovery), 0);
assertEq(token.balanceOf(recovery), 0, "old recovery gets nothing");
}

Recommended Mitigation

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

Support

FAQs

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

Give us feedback!