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

Blacklisted Addresses (USDC/USDT-style) Permanently Brick Pool Operations

Author Revealed upon completion

Description

  • If recoveryAddress, attacker, or any staker is added to token's blocklist:

    • claimCorrupted() → reverts if recovery blacklisted → pool funds permanently stuck

    • claimAttackerBounty() → reverts if attacker blacklisted → bounty unclaimable

    • claimSurvived()/claimExpired() → reverts for blacklisted staker → individual funds stuck

    • withdraw() → reverts for blacklisted staker

  • No fallback pull mechanism or address update for recovery/attacker.

// All outbound transfers use push pattern with no fallback
@> stakeToken.safeTransfer(to, amount); // Reverts if 'to' is blacklisted

Risk

Likelihood:

  • USDC/USDT and similar tokens have centralized blacklist functionality controlled by issuer

  • Blacklisting can occur at any time post-deployment due to sanctions, compliance, or error

Impact:

  • Blacklisted recovery address → claimCorrupted() permanently reverts → pool funds stuck forever

  • Blacklisted attacker → good-faith bounty unclaimable, recovery blocked until 180-day deadline

  • Blacklisted staker → individual funds stuck with no alternative withdrawal mechanism


Proof of Concept

pool = _deployPool(blacklistToken);
_stakeBlacklist(alice, 100 * ONE);
// ... flag CORRUPTED ...
blacklistToken.setBlacklist(recovery, true);
vm.expectRevert("Blacklisted");
pool.claimCorrupted(); // Reverts — funds stuck forever
// FULL TEST
// ============================================================
// M-2: Blacklisted Address Bricks Pool Operations
// ============================================================
function test_M2_BlacklistedRecoveryAddress_BricksClaimCorrupted() public {
pool = _deployPool(address(blacklistToken));
_stakeBlacklist(alice, 100 * ONE, blacklistToken);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Blacklist recovery address AFTER flagging
blacklistToken.setBlacklist(recovery, true);
// claimCorrupted now reverts - funds permanently stuck!
vm.expectRevert("Blacklisted");
pool.claimCorrupted();
// No way to recover funds unless blacklist removed (external dependency)
}
function test_M2_BlacklistedAttacker_BricksGoodFaithBounty() public {
pool = _deployPool(address(blacklistToken));
_stakeBlacklist(alice, 100 * ONE, blacklistToken);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // Good faith
// Blacklist attacker
blacklistToken.setBlacklist(attacker, true);
// claimAttackerBounty reverts - bounty unclaimable
vm.expectRevert("Blacklisted");
vm.prank(attacker);
pool.claimAttackerBounty();
// claimCorrupted reverts MustClaimBountyFirst
vm.expectRevert(IConfidencePool.MustClaimBountyFirst.selector);
pool.claimCorrupted();
// sweepUnclaimedCorrupted only works after 180 days
// Funds stuck for 180 days minimum
}
function test_M2_BlacklistedStaker_BricksIndividualClaim() public {
pool = _deployPool(address(blacklistToken));
_stakeBlacklist(alice, 100 * ONE, blacklistToken);
_stakeBlacklist(bob, 50 * ONE, blacklistToken);
_contributeBonusBlacklist(carol, 30 * ONE, blacklistToken);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Blacklist alice
blacklistToken.setBlacklist(alice, true);
// Alice cannot claim - her funds stuck
vm.expectRevert("Blacklisted");
vm.prank(alice);
pool.claimSurvived();
// Bob can still claim (not blacklisted)
vm.prank(bob);
pool.claimSurvived(); // Works
// Alice's funds permanently stuck unless blacklist removed
}

Recommended Mitigation

+ // Add pull-payment pattern for critical addresses
+ mapping(address => uint256) public pendingWithdrawals;
+
+ function claimCorrupted() external nonReentrant {
+ // ... validation ...
+ pendingWithdrawals[recoveryAddress] += amount;
+ emit CorruptedClaimed(recoveryAddress, amount);
+ }
+
+ function withdrawPending() external {
+ uint256 amount = pendingWithdrawals[msg.sender];
+ require(amount > 0, "Nothing to withdraw");
+ pendingWithdrawals[msg.sender] = 0;
+ stakeToken.safeTransfer(msg.sender, amount);
+ }
+
// Also add address update capability for recovery/attacker
function setRecoveryAddress(address newRecovery) external onlyOwner {
recoveryAddress = newRecovery;
}

Support

FAQs

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

Give us feedback!