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

`recoveryAddress` Can Be Set to the Pool's Own Address

Author Revealed upon completion

Root + Impact

Description

The setRecoveryAddress() function validates only that newRecoveryAddress != address(0). It does not check against address(this) (the pool's own address) or address(stakeToken) (the stake token contract). If set to the pool itself, CORRUPTED sweeps transfer tokens back to the pool — effectively locking them permanently, as no function exists to redistribute funds from a post-CORRUPTED pool. This is an owner-operated footgun: the owner can accidentally or maliciously create a token sinkhole.

// ConfidencePool.sol — setRecoveryAddress() lines 611-618
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
@> if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress(); // <-- only zero blocked
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Risk

Likelihood: Low — requires the trusted pool owner to set recoveryAddress to the pool itself (accidental fat-finger or malicious rug).

Impact: Low — tokens are locked in the pool, not stolen. The owner cannot recover them either. Under the owner-trust model (DESIGN.md §10), the owner could already steal funds via other means.

Proof of Concept

File: L15-RecoveryAddress-Self.poc.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for L-15: recoveryAddress can be set to the pool itself (sinkhole)
contract L15_RecoveryAddress_Self_POC is BaseConfidencePoolTest {
function testPOC_L15_recoveryAddress_canBePoolItself() external {
// The owner can set recoveryAddress to address(pool) — no guard prevents this
pool.setRecoveryAddress(address(pool));
assertEq(pool.recoveryAddress(), address(pool));
// If the moderator flags CORRUPTED, claimCorrupted sweeps to recoveryAddress = pool itself
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// The sweep transfers tokens BACK to the pool — effectively locking them
uint256 poolBalBefore = token.balanceOf(address(pool));
pool.claimCorrupted();
uint256 poolBalAfter = token.balanceOf(address(pool));
// Tokens stayed in the pool (went from pool → pool via safeTransfer)
assertEq(poolBalAfter, poolBalBefore, "tokens trapped in pool");
// No function exists to redistribute post-CORRUPTED funds
}
function testPOC_L15_recoveryAddress_noGuardAgainstSelf() external {
// Only address(0) is blocked; address(this) passes
vm.expectRevert(IConfidencePool.InvalidRecoveryAddress.selector);
pool.setRecoveryAddress(address(0));
// address(pool) succeeds — no check against self
pool.setRecoveryAddress(address(pool));
}
}

Run: forge test --match-path 'L15-RecoveryAddress-Self.poc.t.sol' -vv

Recommended Mitigation

Add checks against address(this) and address(stakeToken) in setRecoveryAddress:

- if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (newRecoveryAddress == address(0) || newRecoveryAddress == address(this)
+ || newRecoveryAddress == address(stakeToken)) revert InvalidRecoveryAddress();

Support

FAQs

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

Give us feedback!