← Back to Blog

Token Blacklist and Blocklist Security: Bypass Techniques, Centralization Risks, and Compliance Gaps

2026-07-27 token-security blacklist compliance access-control erc-20-security censorship-resistance

Many ERC-20 tokens — including stablecoins like USDC, USDT, and regulated securities tokens — implement address blacklisting to comply with legal requirements or freeze stolen funds. Centre Consortium (USDC) has blacklisted over 1,500 addresses, and Tether (USDT) regularly freezes wallets at the request of law enforcement. While the intent is legitimate, blacklist implementations are frequently flawed: bypassable through intermediaries, vulnerable to admin key compromise, or inconsistently applied across token transfer paths.

For DeFi protocol integrators, blacklisted token behavior can be an unexpected source of DoS: if a vault or pool holds a blacklisted address's shares, withdrawal can permanently revert. For token issuers, a flawed blacklist gives a false sense of compliance while leaving enforcement gaps that a sophisticated attacker will exploit. Understanding these weaknesses is essential for protocol integrators, token issuers, and security auditors alike.

1. Blacklist Check Only on transfer, Not transferFrom

The most common implementation mistake is checking the blacklist only in transfer() but not transferFrom(). This asymmetry arises because developers test the direct transfer path but overlook the approval-based path. An operator with a valid approval can move tokens from a blacklisted address via transferFrom(), bypassing the restriction entirely. The ERC-20 standard defines both paths as valid transfer mechanisms, so any compliance-critical guard must cover both.

Vulnerable:

contract BlacklistToken is ERC20 {
    mapping(address => bool) public blacklisted;
    address public admin;

    function transfer(address to, uint256 amount) public override returns (bool) {
        require(!blacklisted[msg.sender], "sender blacklisted");
        require(!blacklisted[to], "recipient blacklisted");
        return super.transfer(to, amount);
    }

    // transferFrom has no blacklist check!
    function transferFrom(address from, address to, uint256 amount)
        public override returns (bool) {
        return super.transferFrom(from, to, amount);
    }
}

An attacker pre-approves a clean address before being blacklisted. That address then calls transferFrom to move the funds out. This is particularly dangerous in protocols where approvals are granted to smart contracts that hold them indefinitely — a blacklisted user's approval to a DEX router or lending protocol remains valid until explicitly revoked or until the protocol's own authorization logic is updated.

Fixed:

contract BlacklistToken is ERC20 {
    mapping(address => bool) public blacklisted;

    modifier notBlacklisted(address from, address to) {
        require(!blacklisted[from], "sender blacklisted");
        require(!blacklisted[to], "recipient blacklisted");
        require(!blacklisted[msg.sender], "operator blacklisted");
        _;
    }

    function transfer(address to, uint256 amount)
        public override notBlacklisted(msg.sender, to) returns (bool) {
        return super.transfer(to, amount);
    }

    function transferFrom(address from, address to, uint256 amount)
        public override notBlacklisted(from, to) returns (bool) {
        return super.transferFrom(from, to, amount);
    }
}

Detection tips: Search for blacklisted mapping usage. Verify both transfer and transferFrom (and any _transfer or _beforeTokenTransfer hook) contain the check. A common pattern is overriding only transfer while _transfer (the internal function) has no guard — if a subclass calls _transfer directly, the restriction is completely bypassed. If only one path is guarded, the restriction is bypassable.

2. Intermediary Contract Bypass

Even a correctly implemented blacklist on direct transfers can be bypassed using an intermediary contract that is not itself blacklisted. The attacker deploys or uses an existing contract as a relay, sending tokens through it to reach the final destination. Because the token sees the intermediary — not the blacklisted sender — as the initiating address, the transfer succeeds.

Vulnerable:

// Attacker deploys this relay contract (not blacklisted)
contract Relay {
    IERC20 public token;

    function relay(address to, uint256 amount) external {
        token.transferFrom(msg.sender, to, amount);
    }
}

If Alice is blacklisted but the Relay contract is not, Alice can approve Relay and call relay(Bob, amount) — the transfer succeeds because neither Relay nor Bob is blacklisted. The token sees msg.sender == Relay.

Fixed:

contract BlacklistToken is ERC20 {
    mapping(address => bool) public blacklisted;

    modifier notBlacklisted(address from, address to) {
        require(!blacklisted[from], "from blacklisted");
        require(!blacklisted[to], "to blacklisted");
        _;
    }

    constructor() ERC20("BlacklistToken", "BLT") {}

    // FIXED: Use OpenZeppelin v5 style _update function instead of v4 _beforeTokenTransfer
    function _update(address from, address to, uint256 value)
        internal override notBlacklisted(from, to) {
        super._update(from, to, value);
    }

    function isBlacklistedPath(address from, address to) external view returns (bool) {
        return blacklisted[from] || blacklisted[to];
    }
}

Detection tips: Intermediary bypass is a design-level limitation of address-based blacklists. No purely on-chain mechanism can prevent a blacklisted address from using an un-blacklisted proxy contract unless every contract interacting with the token is also checked. Evaluate whether the token issuer has off-chain monitoring (Chainalysis, TRM Labs) to detect unusual transfer chains and retroactively blacklist relay contracts. For integrators: if your protocol interacts with a blacklistable token, document the risk that a user's redemption may route through a relay and trigger unexpected reverts.

3. Blacklist Admin Key Compromise

A single EOA controlling the blacklist is a high-value target. A compromised admin key can blacklist any address — including protocol contracts, liquidity pools, or token issuers themselves — effectively bricking the token ecosystem. Beyond compromise, a malicious admin can weaponize the blacklist to selectively freeze competitors, manipulate governance outcomes (blacklist voting tokens), or extort users by threatening to freeze accounts. This privileged operation requires the strongest key management controls in the entire protocol.

Vulnerable:

contract BlacklistToken is ERC20 {
    address public blacklistAdmin;
    mapping(address => bool) public blacklisted;

    function addToBlacklist(address account) external {
        require(msg.sender == blacklistAdmin, "not admin");
        blacklisted[account] = true;
    }

    function removeFromBlacklist(address account) external {
        require(msg.sender == blacklistAdmin, "not admin");
        blacklisted[account] = false;
    }
}

Fixed:

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

// FIXED: Declare events, inheritance modifiers, and correct parent constructors
contract BlacklistToken is ERC20, AccessControl {
    bytes32 public constant BLACKLIST_ROLE = keccak256("BLACKLIST_ROLE");
    bytes32 public constant BLACKLIST_ADMIN_ROLE = keccak256("BLACKLIST_ADMIN_ROLE");

    mapping(address => bool) public blacklisted;

    event Blacklisted(address indexed account);
    event Unblacklisted(address indexed account);

    // Require 2-of-3 multisig for blacklist operations via timelock
    constructor(address multisig) ERC20("BlacklistToken", "BLT") {
        _grantRole(DEFAULT_ADMIN_ROLE, multisig);
        _grantRole(BLACKLIST_ADMIN_ROLE, multisig);
        _setRoleAdmin(BLACKLIST_ROLE, BLACKLIST_ADMIN_ROLE);
    }

    function addToBlacklist(address account) external onlyRole(BLACKLIST_ROLE) {
        require(account != address(0), "zero address");
        blacklisted[account] = true;
        emit Blacklisted(account);
    }

    function removeFromBlacklist(address account) external onlyRole(BLACKLIST_ROLE) {
        blacklisted[account] = false;
        emit Unblacklisted(account);
    }

    // Required override by Solidity compiler for dual inheritance
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC20, AccessControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Detection tips: Check if blacklistAdmin or equivalent is a single EOA. Production compliance tokens should use a 3-of-5 multisig or a DAO-controlled timelock for blacklist mutations. Additionally, verify whether the blacklist admin role can also modify the token supply or upgrade the implementation — privilege separation between compliance operations and protocol governance is essential for defense in depth.

4. No Freeze of Allowances on Blacklist

When an address is blacklisted, existing token allowances granted by that address remain active. Before the approvals expire or are revoked, approved operators can still drain the blacklisted account via transferFrom.

Vulnerable:

function addToBlacklist(address account) external onlyAdmin {
    blacklisted[account] = true;
    // Existing approvals remain — operators can still call transferFrom
}

Fixed:

contract BlacklistToken is ERC20 {
    address public admin;
    mapping(address => bool) public blacklisted;

    event Blacklisted(address indexed account);
    event BlacklistAllowanceWarning(address indexed account);

    modifier onlyAdmin() {
        require(msg.sender == admin, "not admin");
        _;
    }

    constructor() ERC20("BlacklistToken", "BLT") {
        admin = msg.sender;
    }

    function addToBlacklist(address account) external onlyAdmin {
        blacklisted[account] = true;
        emit Blacklisted(account);
        emit BlacklistAllowanceWarning(account); // off-chain alert to revoke approvals
    }

    // FIXED: override _spendAllowance to reject blacklisted owner/spender
    function _spendAllowance(address owner, address spender, uint256 amount)
        internal override {
        require(!blacklisted[owner], "owner blacklisted");
        require(!blacklisted[spender], "spender blacklisted");
        super._spendAllowance(owner, spender, amount);
    }
}

Detection tips: Check whether _spendAllowance or _approve validates the blacklist. Verify the blacklisting process documentation addresses handling of existing allowances. A compliant implementation should either (a) zero out all existing allowances as part of the blacklisting transaction, (b) override _spendAllowance to enforce the blacklist at spend time, or (c) emit events for off-chain monitoring systems to trigger a revocation flow.

5. Blacklist Applied After Token Wrap/Bridge

Tokens bridged to L2 or wrapped (e.g., into an LP position or vault share) hold the underlying token. If the underlying token blacklists an address, the wrap contract itself may become the "owner" of the tokens from the protocol's perspective — and may or may not propagate the restriction.

Vulnerable:

// Alice deposits USDC into vault and receives vault shares
// Alice is then blacklisted on USDC
// Alice holds vault shares (not USDC) — blacklist doesn't apply to shares
// Alice redeems shares → vault transfers USDC to Alice using transferFrom
// This may revert if vault's transfer hits Alice's blacklisted address

Fixed:

interface IBlacklistToken {
    function isBlacklisted(address account) external view returns (bool);
}

contract Vault {
    IERC20 public token; // may be blacklistable
    uint256 public totalShares;

    function totalAssets() public view returns (uint256) {
        return token.balanceOf(address(this));
    }

    function _burn(address user, uint256 shares) internal {
        // burn shares logic
    }

    function withdraw(uint256 shares) external {
        uint256 assets = (shares * totalAssets()) / totalShares;
        _burn(msg.sender, shares);

        // Check if recipient is blacklisted before transfer
        try IBlacklistToken(address(token)).isBlacklisted(msg.sender) returns (bool bl) {
            require(!bl, "recipient blacklisted on underlying token");
        } catch {} // token doesn't have the function — proceed

        token.transfer(msg.sender, assets);
    }
}

Detection tips: Vaults and bridges integrating compliance tokens should check whether the underlying token is blacklistable and handle the case where transfer reverts due to blacklist mid-operation. Recommended mitigations: (1) wrap withdrawals in a try/catch that emits an event rather than reverting the entire tx, (2) implement a claim-later pattern where failed transfers queue assets for manual redemption, (3) document in the protocol's risk disclosure that compliance-token blacklisting may affect user withdrawals. Treat blacklistable tokens as a special token class in your integration checklist, similar to fee-on-transfer or rebase tokens.

6. Blacklist Bypass via Upgradeable Proxy

Upgradeable token contracts can have their blacklist logic silently removed or altered in a proxy upgrade. If the upgrade admin is not sufficiently decentralized, an attacker who compromises the admin can deploy a new implementation with no blacklist — immediately unblocking all frozen addresses.

Vulnerable:

// V1 implementation — has blacklist
contract TokenV1 is ERC20Upgradeable {
    mapping(address => bool) public blacklisted;
    function transfer(address to, uint256 amount) public override returns (bool) {
        require(!blacklisted[msg.sender] && !blacklisted[to], "blacklisted");
        return super.transfer(to, amount);
    }
}

// Attacker upgrades to V2 — no blacklist
contract TokenV2 is ERC20Upgradeable {
    function transfer(address to, uint256 amount) public override returns (bool) {
        return super.transfer(to, amount); // no blacklist check
    }
}

Fixed:

// Proxy admin must be a timelock with minimum 48-hour delay
// Any upgrade that removes compliance functions should be flagged
interface IUpgradeableProxy {
    function upgradeTo(address newImpl) external;
}

contract TimelockUpgradeAdmin {
    uint256 public constant MIN_DELAY = 48 hours;
    address public governance;
    mapping(bytes32 => uint256) public queuedUpgrades;

    event UpgradeQueued(address indexed proxy, address indexed newImpl, uint256 unlockTime);

    modifier onlyGovernance() {
        require(msg.sender == governance, "not governance");
        _;
    }

    constructor(address _gov) {
        governance = _gov;
    }

    function queueUpgrade(address proxy, address newImpl) external onlyGovernance {
        bytes32 id = keccak256(abi.encodePacked(proxy, newImpl));
        queuedUpgrades[id] = block.timestamp + MIN_DELAY;
        emit UpgradeQueued(proxy, newImpl, queuedUpgrades[id]);
    }

    function executeUpgrade(address proxy, address newImpl) external onlyGovernance {
        bytes32 id = keccak256(abi.encodePacked(proxy, newImpl));
        require(block.timestamp >= queuedUpgrades[id], "timelock not elapsed");
        IUpgradeableProxy(proxy).upgradeTo(newImpl);
    }
}

Detection tips: For compliance tokens, verify the upgrade path. A 24-hour or shorter timelock on an upgradeable compliance token provides insufficient time for regulators or security researchers to react to a malicious upgrade. Audit whether the new implementation preserves all compliance-critical storage slots (blacklist mapping, admin roles) and whether there are automated checks in the CI/CD pipeline that fail if blacklist logic is removed from a proposed implementation. Immutable compliance logic (non-upgradeable blacklist module) is the highest-assurance option.


Blacklist implementations are deceptively simple but have many failure modes that only appear under adversarial conditions. The most dangerous assumption is that adding a require(!blacklisted[msg.sender]) guard to transfer() is sufficient — in practice, a complete implementation must cover transferFrom, _spendAllowance, every hook (_beforeTokenTransfer, _afterTokenTransfer), and all admin key management paths. Protocol integrators must additionally audit how their vaults and bridges handle tokens that may silently revert or partially succeed. ContractScan checks your token contract for incomplete blacklist coverage across all transfer paths, allowance freeze gaps, and upgrade-based bypass risks.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →