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

Blacklisted Whitehat Attacker Causes 180-Day Denial of Service for claimCorrupted Sweep

Author Revealed upon completion

Root + Impact

Description

  • Normally, during a good-faith CORRUPTED resolution, the designated whitehat attacker must claim their bounty via claimAttackerBounty(). Once the bounty is fully claimed, claimCorrupted() can be called to sweep any excess funds (or staker principal) to the recoveryAddress.

  • However, if the pool uses a highly adopted standard ERC20 token that contains a blacklist mechanism (e.g., USDC or USDT) and the designated whitehat attacker's address is blacklisted by the token issuer, claimAttackerBounty() will permanently revert. Because claimCorrupted() contains a strict MustClaimBountyFirst() gate, it will also revert. This traps all sweepable funds in the contract for 180 days until sweepUnclaimedCorrupted() becomes available.

  • Note on Protocol Design Assumptions:

    While Section 12 of the DESIGN.md states that a stuck-state would require a "defective stake token... which the factory allowlist exists to exclude," it is critical to highlight that USDC and USDT are not 'defective' tokens. They are the absolute industry standard for stablecoins and are highly likely to be allowlisted to attract stakers. Their blacklist mechanism is a standard regulatory compliance feature, not a code defect.

    Relying on the factory allowlist to exclude the most liquid tokens in DeFi (such as USDC/USDT) just to avoid this specific bug is a poor architectural choice that severely limits protocol adoption. The provided mitigation elegantly fixes the stuck-state issue without restricting the protocol from using standard, widely-adopted stablecoins.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
// @> Root cause: Strict gate prevents sweeping if attacker cannot claim
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
// ...
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

  • The Factory owner allowlists a token with a blacklist feature (like USDC or USDT, which are explicitly standard ERC20s and the most common stablecoins).

  • The Moderator flags a good-faith CORRUPTED outcome and names an attacker address that happens to be blacklisted by the token issuer (e.g., due to OFAC sanctions or suspicious activity alerts on the hacker's address).

Impact:

  • The claimCorrupted() function is completely blocked.

  • All excess funds and stakers' principal destined for the recoveryAddress are locked inside the contract for CORRUPTED_CLAIM_WINDOW (180 days). This is a severe Denial of Service that forces a massive delay in fund recovery.

Proof of Concept

  • The following step-by-step scenario demonstrates how a blacklisted whitehat address causes the entire sweep function to revert, permanently trapping the remaining staker funds in the contract for the duration of the claim window.

1. The Factory Admin allows USDC as `stakeToken`.
2. A new Confidence Pool is created and stakers deposit 100,000 USDC.
3. An ethical hacker exploits the underlying agreement and successfully returns the funds to the Safe Harbor.
4. The Moderator calls `flagOutcome(CORRUPTED, true, hackerAddress)` to award the bounty.
5. The `hackerAddress` gets flagged by Circle (USDC) and is added to the blacklist.
6. The hacker attempts to call `claimAttackerBounty()`, but the USDC `transfer` reverts due to the blacklist.
7. The sponsor attempts to call `claimCorrupted()` to sweep the rest of the pool to the `recoveryAddress`, but it reverts with `MustClaimBountyFirst()`.
8. The 100,000 USDC is locked in the pool for 180 days until `sweepUnclaimedCorrupted()` can finally bypass the check.

Recommended Mitigation

  • The proposed mitigation removes the strict requirement that the bounty must be claimed before sweeping. Instead, it calculates the pending unpaid bounty, reserves it within the contract's balance, and immediately sweeps the remaining excess funds to the recovery address. This ensures that even if the attacker's transfer reverts, the stakers' funds can still be safely recovered without delay.

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

Support

FAQs

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

Give us feedback!