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

A blocklist-capable stake token (USDC/USDT) freezes the CORRUPTED payout path — the allowlist reasoning covers only fee-on-transfer/rebasing, not recipient-blocklist reverts

Author Revealed upon completion

Root + Impact

Description

  • On a good-faith CORRUPTED resolution the entire pool is reserved as the named whitehat's bounty; claimAttackerBounty() pays the attacker and unblocks claimCorrupted(). docs/DESIGN.md §12 asserts there is no stuck-state because "a true stuck-state would require a defective stake token that freezes the pool's own balance, which the factory allowlist exists to exclude", and the factory allowlist natspec names only fee-on-transfer and rebasing tokens.

  • That reasoning is incomplete: a compliance blocklist token — USDC/USDT, the canonical stablecoins a pool would use — is a standard ERC20 (no fee, no rebasing) that IS allowlistable, yet it reverts on transfer to a blocklisted recipient. When the moderator-named good-faith attacker is on the issuer blocklist, claimAttackerBounty's payout reverts and can never be claimed; claimCorrupted then stays permanently gated by MustClaimBountyFirst. The whole pool is frozen for the 180-day CORRUPTED_CLAIM_WINDOW, after which anyone sweeps it to recoveryAddress — the whitehat who performed the in-scope breach receives 0.

// claimCorrupted() — good-faith CORRUPTED is gated behind the attacker bounty
@> if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
// claimAttackerBounty() — the payout reverts if the named attacker is blocklisted
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
@> stakeToken.safeTransfer(attacker, payout); // reverts: attacker on the USDC/USDT blocklist
}
// ConfidencePoolFactory — the allowlist rationale §12 relies on names ONLY FoT/rebasing:
@> /// Pools assume a standard ERC20 (no transfer fees, no rebasing); fee-on-transfer
/// tokens ... and fee-on-sender or negative-rebasing tokens erode the pool balance ...
// (a recipient-blocklist standard ERC20 is NOT excluded by this reasoning)

Risk

Likelihood:

  • The pool's allowlisted stake token is a blocklist-capable standard ERC20 (USDC or USDT — the dominant stablecoins for this use case), which the allowlist rationale does not exclude.

  • The moderator-named good-faith attacker (or the sponsor's recoveryAddress) is on the token issuer's blocklist at claim time, and the moderator does not re-name a clean attacker within the correction window.

Impact:

  • The full CORRUPTED payout path is frozen for up to 180 days (claimAttackerBounty reverts; claimCorrupted is blocked by MustClaimBountyFirst).

  • A blocklisted named whitehat who performed the in-scope breach ultimately receives 0; the pool sweeps to recoveryAddress after the window elapses.

Proof of Concept

function test_goodFaithBounty_blocklistedWhitehat_noReflag_sweepsToRecovery() external {
// good-faith CORRUPTED with a blocklisted named attacker (whitehat)
_stake(alice, 100e18);
_reachCorruptedGoodFaith(whitehat); // moderator flags CORRUPTED good-faith, names whitehat
token.setBlocklisted(whitehat, true); // whitehat is on the USDC/USDT blocklist
vm.prank(whitehat);
vm.expectRevert(); // safeTransfer(attacker) reverts -> bounty unclaimable
pool.claimAttackerBounty();
vm.expectRevert(); // claimCorrupted blocked by MustClaimBountyFirst
pool.claimCorrupted();
vm.warp(corruptedClaimDeadline + 1); // 180-day window elapses
pool.sweepUnclaimedCorrupted(); // anyone sweeps the pool to recoveryAddress
assertEq(token.balanceOf(whitehat), 0); // the whitehat who breached in-scope receives nothing
}

Recommended Mitigation

+ mapping(address => uint256) public attackerBountyCredit;
function claimAttackerBounty() external nonReentrant {
...
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(attacker, payout);
+ // pull-payment: credit the bounty and let the attacker withdraw to a clean address,
+ // so a single reverting recipient cannot freeze the whole CORRUPTED payout path.
+ attackerBountyCredit[attacker] += payout;
}
}
+
+ function withdrawAttackerBounty(address to) external nonReentrant {
+ uint256 amt = attackerBountyCredit[msg.sender];
+ require(amt != 0);
+ attackerBountyCredit[msg.sender] = 0;
+ stakeToken.safeTransfer(to, amt);
+ }

Support

FAQs

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

Give us feedback!