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

Mutable recoveryAddress breaks trust assumption and can expose stakers to malicious manipulation

Author Revealed upon completion

Root + Impact

DESIGN.md tells stakers that all pool state is on-chain and that they must evaluate it before depositing. Every parameter becomes locked once the agreement is under attack, except recoveryAddress, which the owner can replace at any time. So the single value stakers are told to rely on is the one value that is not committed. This makes the required evaluation pointless and exposes stakers to malicious manipulation.

Description

  • recoveryAddress is the destination for the whole pool under a bad-faith CORRUPTED outcome. DESIGN.md instructs stakers to evaluate the on-chain pool parameters before depositing, and every other parameter is fixed by the time the pool is under attack: expiry locks on the first stake, scope locks as soon as the registry leaves pre-attack staging, and the stake token is immutable from creation.

  • setRecoveryAddress carries no such lock, and this can be abused. A sponsor creates a pool over his own account and sets recoveryAddress to a community redistribution address, signalling such confidence in his own protocol that even a corruption would not pay him. Stakers deposit on that trust basis. Once the Agreement reaches active risk and withdrawals are permanently disabled, the sponsor swaps recoveryAddress for a private wallet, and after a bad-faith CORRUPTED flag claimCorrupted sweeps the entire pool to it.

// src/ConfidencePool.sol::setRecoveryAddress — no lock, unlike expiry (compare expiryLocked)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
@> recoveryAddress = newRecoveryAddress; // changeable at any point in the lifecycle
...
}
// src/ConfidencePool.sol::claimCorrupted — sweeps to the current address, read live
uint256 toSweep = stakeToken.balanceOf(address(this));
@> stakeToken.safeTransfer(recoveryAddress, toSweep);

Risk

Likelihood:

  • The swap is always available to the sponsor at no cost, since setRecoveryAddress is onlyOwner with no lock and the sponsor is the pool owner.

  • It pays off on any bad-faith CORRUPTED settlement, performed after the Agreement reaches active risk and withdrawals are permanently disabled. The moderator flags the outcome honestly and the pool still goes to the swapped address; no collusion or staker error is required.

  • This does require a malicious owner, which is rare.

Likelihood should therefore be set at Low.

Impact:

  • 100% of the pool, staker principal plus sponsor bonus, is redirected from the reviewed beneficiary to an address stakers never saw and can no longer reject.

  • The documented pre-deposit review is defeated. A parameter stakers were told to rely on becomes a post-commitment owner option, exercised after they have lost their exit.

Impact is High.

Proof of Concept

poc/RecoveryAddressRugPoc.t.sol commits a 20-token sponsor bonus and 100 tokens of staker principal, disables withdrawal, swaps the beneficiary, and confirms the replacement receives all 120 tokens.

function test_poc_ownerRedirectsFullPoolAfterStakeIsLocked() public {
_contributeBonus(address(this), SPONSOR_BONUS);
_stake(alice, STAKER_PRINCIPAL);
// Stakers lose their withdrawal escape hatch once active risk is observed.
_passThroughUnderAttack();
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
// The owner can still replace the beneficiary after this point.
assertEq(pool.recoveryAddress(), recovery);
pool.setRecoveryAddress(replacementRecovery);
assertEq(pool.recoveryAddress(), replacementRecovery);
// A genuine bad-faith outcome pays every token to the replacement address.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 0);
assertEq(token.balanceOf(replacementRecovery), SPONSOR_BONUS + STAKER_PRINCIPAL);
assertEq(token.balanceOf(address(pool)), 0);
}

Recommended Mitigation

Lock recoveryAddress on the first stake, reusing the expiryLocked latch that already protects expiry:

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (expiryLocked) revert RecoveryAddressLocked();
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!