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

A blacklisted `recoveryAddress` freezes every CORRUPTED/sweep path until the owner repoints

Author Revealed upon completion

Every fund-recovery path pushes tokens to recoveryAddress via a single unconditional safeTransfer, with no pull-based fallback if that transfer can revert. If the stake token can blacklist addresses and recoveryAddress gets blacklisted, all CORRUPTED/bonus-sweep payouts revert and pool funds are frozen until the owner calls setRecoveryAddress to repoint — a temporary, owner-recoverable denial of service, not a fund loss.

Description

The protocol's compatibility statement supports "standard ERC20"; USDC/USDT are standard, non-fee,
non-rebasing tokens whose only non-vanilla behavior is an issuer-controlled blacklist. Blacklisting
of an address is a plausible, real-world token behavior that a token-accepting contract must tolerate.

The CORRUPTED resolution and both sweep helpers move the entire relevant balance to recoveryAddress
in a single push transfer. If that address is blacklisted by the token, the transfer reverts and the
resolution cannot complete.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
@=> stakeToken.safeTransfer(recoveryAddress, toSweep); // reverts if recoveryAddress is blacklisted
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}

The same push-to-recoveryAddress pattern appears in
sweepUnclaimedCorrupted (L468)
and sweepUnclaimedBonus (L506).

Risk

Likelihood:

  • Low-to-medium. It requires (a) the pool's stake token to be a blacklistable token — very likely if
    USDC/USDT are allowlisted — and (b) the specific recoveryAddress to be blacklisted, which is
    uncommon but does happen (OFAC actions, compromised-address freezes). No attacker action is needed;
    it is an availability/robustness gap rather than an exploit.

Impact:

  • Impact is a temporary freeze of funds, not a loss. Because recoveryAddress is owner-controlled
    via setRecoveryAddress, the owner can repoint it to a non-blacklisted address and re-run the sweep,
    so the funds are always recoverable. The blocked destination is the sponsor's own recovery target, so
    the party inconvenienced is largely the sponsor themselves.

Proof of Concept

Create the next file `test/TestRecoveryBlacklistDoS.sol` paste the next file and run `forge test --mc MockBlacklistERC20`

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.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 {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract MockBlacklistERC20 is ERC20 {
mapping(address => bool) public blacklisted;
constructor() ERC20("Blacklist", "BL") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setBlacklisted(address account, bool value) external {
blacklisted[account] = value;
}
function _update(address from, address to, uint256 value) internal override {
require(!blacklisted[from] && !blacklisted[to], "blacklisted");
super._update(from, to, value);
}
}
contract TestRecoveryBlacklistDoS is BaseConfidencePoolTest {
MockBlacklistERC20 internal blToken;
ConfidencePool internal blPool;
function _deployBlacklistPool() internal returns (ConfidencePool p) {
ConfidencePool implementation = new ConfidencePool();
p = ConfidencePool(Clones.clone(address(implementation)));
p.initialize(
agreement,
address(blToken),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _stakeBl(address user, uint256 amount) internal {
blToken.mint(user, amount);
vm.startPrank(user);
blToken.approve(address(blPool), amount);
blPool.stake(amount);
vm.stopPrank();
}
function testBlacklistedRecoveryBlocksCorruptedSweepThenRecovers() external {
blToken = new MockBlacklistERC20();
blPool = _deployBlacklistPool();
// Alice stakes; the agreement is then breached in-scope.
_stakeBl(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Bad-faith CORRUPTED: the whole pool is destined for recoveryAddress.
vm.prank(moderator);
blPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// The token issuer blacklists the recovery address.
blToken.setBlacklisted(recovery, true);
// The sweep now reverts: the funds are stuck for as long as the blacklist stands.
vm.expectRevert(bytes("blacklisted"));
blPool.claimCorrupted();
// Recovery: the pool owner repoints recoveryAddress to a clean address and the sweep works.
address cleanRecovery = makeAddr("cleanRecovery");
blPool.setRecoveryAddress(cleanRecovery);
blPool.claimCorrupted();
assertEq(blToken.balanceOf(cleanRecovery), 100 * ONE, "sweep succeeds after repointing recovery");
}
}

Recommended Mitigation

Prefer a pull-over-push pattern for the recovery destination, or wrap the sweep so a blacklisted
destination does not brick the call. A minimal, low-risk mitigation is to document the owner's
obligation to keep recoveryAddress transfer-eligible and to expose the repoint as the sanctioned
remediation (already possible via setRecoveryAddress). A stronger fix converts the recovery payout
into a credit the recovery address pulls:

- if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(recoveryAddress, toSweep);
- emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
+ if (!claimsStarted) claimsStarted = true;
+ pendingRecovery[recoveryAddress] += toSweep; // recovery address later pulls via a withdraw fn
+ emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);

Support

FAQs

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

Give us feedback!