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

CORRUPTED-path disbursements push to a single mutable `recoveryAddress` with no pull fallback, so a token blacklist (USDC/USDT-style) or a reverting recipient temporarily bricks the entire sweep until the owner retargets

Author Revealed upon completion

## Description
All three CORRUPTED-path disbursements push tokens to `recoveryAddress` via `safeTransfer` with no
try/catch and no pull-based fallback:
- `claimCorrupted``recoveryAddress` (`ConfidencePool.sol:423`)
- `sweepUnclaimedCorrupted``recoveryAddress` (`468`)
- `sweepUnclaimedBonus``recoveryAddress` (`506`)
If the stake token blacklists `recoveryAddress` (e.g. USDC/USDT issuer/OFAC action) or
`recoveryAddress` is a contract that reverts on receipt, these calls revert and the funds are stuck
**until the owner retargets** via `setRecoveryAddress` (`611-618`, owner-only, no timelock, callable
any time).
## 3. Impact
- CORRUPTED disbursement can be temporarily bricked; funds are **not permanently lost** because the
owner can call `setRecoveryAddress` to a clean address and re-run the sweep.
- Only **sponsor-destined** funds are affected (bad-faith full sweep, good-faith residual/dust,
or SURVIVED/EXPIRED excess). Staker principal (pull) is unaffected.
- Requires either external token blacklist of the recovery address or owner misconfiguration.
## 2. Root cause (code)
```solidity
// src/ConfidencePool.sol:423
stakeToken.safeTransfer(recoveryAddress, toSweep); // claimCorrupted
// :468
stakeToken.safeTransfer(recoveryAddress, amount); // sweepUnclaimedCorrupted
// :506
stakeToken.safeTransfer(recoveryAddress, amount); // sweepUnclaimedBonus
// :611-618 — owner can retarget at any time (the mitigation)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = newRecoveryAddress;
...
}
```
Because `recoveryAddress` is owner-mutable at any time, this is the canonical **configurable-recipient DoS where the victim is also the remediator**: funds are not permanently lost, but disbursement is wedged until an active, honest owner calls `setRecoveryAddress` to a clean address and re-runs the sweep. If the owner is unavailable, the funds sit trapped.
Note the asymmetry that bounds the blast radius: **staker payouts are per-staker pull to `msg.sender`** (`claimSurvived:403`, `claimExpired:601`, `claimAttackerBounty:449`) and are therefore isolated. Only **sponsor-destined** funds (the bad-faith full sweep, good-faith residual/dust, or SURVIVED/EXPIRED excess) route through the shared `recoveryAddress` and can be bricked.
**Root cause (GitHub permalinks):**
- `claimCorrupted` push to `recoveryAddress`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L423
- `sweepUnclaimedCorrupted` push: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L468
- `sweepUnclaimedBonus` push: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L506
- `setRecoveryAddress` (owner-only remediation, no timelock): https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L611-L618

## Risk
**Likelihood:**
- Requires either an external token-issuer blacklist of `recoveryAddress` (a real, out-of-protocol event for USDC/USDT-class tokens) or a `recoveryAddress` set to a contract that reverts on receipt.
- Blacklisting tokens are in the intended stake-token set (standard ERC20), and `docs/DESIGN.md` does not document blacklist consequences for the recovery sink — so the behavior is undocumented even though its severity is low.
**Impact:**
- CORRUPTED disbursement (and SURVIVED/EXPIRED excess sweep) can be **temporarily bricked**; funds are **not permanently lost** because the owner can retarget and re-sweep.

Proof of Concept

```solidity
// 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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract BlacklistERC20_HS3 is ERC20 {
mapping(address => bool) public blocked;
constructor() ERC20("BL", "BL") {}
function mint(address to, uint256 a) external { _mint(to, a); }
function setBlocked(address a, bool b) external { blocked[a] = b; }
function _update(address from, address to, uint256 v) internal override {
require(!blocked[from] && !blocked[to], "BLACKLISTED");
super._update(from, to, v);
}
}
/// HS-3: All three CORRUPTED-path disbursements push to recoveryAddress with no pull fallback. If
/// recoveryAddress is token-blacklisted (USDC/USDT issuer action) the sweep reverts permanently and
/// the whole pool is trapped until the owner retargets via setRecoveryAddress.
contract PoCHS3_CorruptedSweepRecoveryBlacklist is Test {
uint256 constant ONE = 1e18;
uint256 constant BASE_TIMESTAMP = 1_750_000_000;
BlacklistERC20_HS3 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address agreement;
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address alice = makeAddr("alice");
function _scope() internal returns (address[] memory a) {
a = new address[](1);
a[0] = makeAddr("scopeAccount");
}
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new BlacklistERC20_HS3();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(makeAddr("scopeAccount"), true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
pool.initialize(
agreement, address(token), address(safeHarborRegistry), moderator,
block.timestamp + 31 days, ONE, recovery, address(this), _scope()
);
}
function _stake(address u, uint256 amt) internal {
token.mint(u, amt);
vm.startPrank(u);
token.approve(address(pool), amt);
pool.stake(amt);
vm.stopPrank();
}
function test_HS3_badFaithCorrupted_bricksWhenRecoveryBlacklisted_thenOwnerUnblocks() public {
_stake(alice, 100 * ONE);
// Reach terminal CORRUPTED with an observed risk window.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0)); // bad-faith
// Token issuer blacklists the recovery address.
token.setBlocked(recovery, true);
// Sweep to recovery bricks — full pool principal trapped.
vm.expectRevert(bytes("BLACKLISTED"));
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), 100 * ONE, "pool funds trapped while recovery blacklisted");
// Only an active, honest owner retargeting recovery can unblock.
address recovery2 = makeAddr("recovery2");
pool.setRecoveryAddress(recovery2);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery2), 100 * ONE, "unblocked only after owner retarget");
}
function test_HS3_goodFaithSweepAlsoBricks() public {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
address wh = makeAddr("whitehat");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wh); // good-faith, named attacker
// Attacker claims the whole pool bounty.
vm.prank(wh);
pool.claimAttackerBounty();
assertEq(token.balanceOf(wh), 100 * ONE, "attacker got full pool");
// A post-resolution donation lands; sweepUnclaimedCorrupted after window would push to
// recovery — blacklist it and show the residual-sweep bricks.
token.mint(address(pool), 5 * ONE);
token.setBlocked(recovery, true);
vm.warp(pool.corruptedClaimDeadline() + 1);
vm.expectRevert(bytes("BLACKLISTED"));
pool.sweepUnclaimedCorrupted();
}
}
```
### Test outcome (verified)
```
Ran 2 tests for test/_scpocs/PoCHS3_CorruptedSweepRecoveryBlacklist.t.sol:PoCHS3_CorruptedSweepRecoveryBlacklist
[PASS] test_HS3_badFaithCorrupted_bricksWhenRecoveryBlacklisted_thenOwnerUnblocks() (gas: 504047)
[PASS] test_HS3_goodFaithSweepAlsoBricks() (gas: 579081)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Add a **pull-based recovery escrow** so a reverting/blacklisted recipient cannot wedge disbursement: record the amount owed to `recoveryAddress` and let the recovery party (or a fresh owner-set address) `pull` it, instead of pushing on the CORRUPTED/sweep path.
```diff
- stakeToken.safeTransfer(recoveryAddress, toSweep);
+ recoveryOwed += toSweep; // credit, do not push
...
+ /// @notice Pull the credited recovery balance (avoids a blacklisted/reverting push wedging the pool).
+ function withdrawRecovery() external {
+ uint256 owed = recoveryOwed;
+ recoveryOwed = 0;
+ stakeToken.safeTransfer(recoveryAddress, owed);
+ }
```

Support

FAQs

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

Give us feedback!