← Back to Blog

NFT Royalty Vulnerabilities: EIP-2981 Bypass Techniques and Secondary Market Security

2026-04-18 nft-security eip-2981 royalties solidity smart-contracts secondary-market

NFT royalties were supposed to be one of Web3's most compelling creator-economy primitives: a mechanism that automatically compensates artists and builders every time their work changes hands on secondary markets. EIP-2981 formalized the interface in 2021, and OpenZeppelin's ERC2981 implementation made it trivially easy to add royalty logic to any token contract. Yet royalty theft remains widespread, and many exploits don't require a sophisticated attacker—they stem from mundane Solidity mistakes that any automated scanner can catch.

This post breaks down six recurring vulnerability classes in EIP-2981 royalty implementations. For each one you'll find a vulnerable code example, a clear explanation of the impact, a corrected version, and concrete detection tips.


1. royaltyInfo Not Enforced On-Chain

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";

contract SimpleNFT is ERC721, ERC2981 {
    constructor() ERC721("SimpleNFT", "SNFT") {
        _setDefaultRoyalty(msg.sender, 500); // 5%
    }

    function mint(address to, uint256 tokenId) external {
        _mint(to, tokenId);
    }
}

Explanation

EIP-2981 is a reporting standard, not an enforcement mechanism. royaltyInfo() tells marketplaces how much royalty to pay and to whom—but the ERC-721 transfer functions (transferFrom, safeTransferFrom) have no built-in hook to validate that payment actually occurred. Any marketplace, aggregator, or peer-to-peer trader can call transferFrom directly, completely ignoring the royalty data returned by royaltyInfo. OpenSea, Blur, and others have repeatedly shown that operator fee policies are negotiable business decisions, not protocol-level guarantees.

On-chain enforcement requires intercepting transfers. ERC-721C (Limit Break's extension) and similar approaches use _beforeTokenTransfer or operator allow-lists to reject transfers that don't originate from trusted, royalty-paying marketplaces. Without such a hook, royaltyInfo is advisory only.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract EnforcedRoyaltyNFT is ERC721, ERC2981, Ownable {
    mapping(address => bool) public approvedMarketplaces;

    constructor() ERC721("EnforcedNFT", "ENFT") Ownable(msg.sender) {
        _setDefaultRoyalty(msg.sender, 500);
    }

    function setApprovedMarketplace(address market, bool approved) external onlyOwner {
        approvedMarketplaces[market] = approved;
    }

    function _update(address to, uint256 tokenId, address auth)
        internal override returns (address)
    {
        address from = _ownerOf(tokenId);
        // Allow mints (from == address(0)) and approved operators only
        if (from != address(0)) {
            require(
                approvedMarketplaces[msg.sender] || msg.sender == from,
                "Transfers only via approved marketplaces"
            );
        }
        return super._update(to, tokenId, auth);
    }

    function mint(address to, uint256 tokenId) external onlyOwner {
        _mint(to, tokenId);
    }
}

Detection Tips


2. Royalty Receiver Set to Zero Address

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ZeroAddressBug is ERC721, ERC2981, Ownable {
    constructor() ERC721("ZeroBug", "ZBUG") Ownable(msg.sender) {}

    // No zero-address check on receiver
    function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
        _setDefaultRoyalty(receiver, feeNumerator);
    }
}

Explanation

OpenZeppelin's _setDefaultRoyalty does validate that receiver != address(0) in recent versions, but custom implementations or forked versions frequently drop this guard. When the receiver is set to the zero address, any marketplace that respects EIP-2981 will send royalty payments to address(0)—effectively burning ETH or ERC-20 tokens. Funds sent to the zero address are unrecoverable. The creator loses all secondary-sale income silently; nothing reverts, and the transaction appears successful.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SafeRoyaltyReceiver is ERC721, ERC2981, Ownable {
    constructor(address royaltyRecipient) ERC721("SafeNFT", "SNFT") Ownable(msg.sender) {
        require(royaltyRecipient != address(0), "Zero address receiver");
        _setDefaultRoyalty(royaltyRecipient, 500);
    }

    function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
        require(receiver != address(0), "Zero address receiver");
        _setDefaultRoyalty(receiver, feeNumerator);
    }
}

Detection Tips


3. Royalty Percentage Exceeds 100%

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract UnboundedRoyalty is ERC721, ERC2981, Ownable {
    constructor() ERC721("UnboundedNFT", "UNFT") Ownable(msg.sender) {}

    // feeNumerator can be set to any value, including > 10000
    function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
        _setDefaultRoyalty(receiver, feeNumerator); // no cap enforced here
    }
}

Explanation

EIP-2981 uses a fee denominator of 10,000 by default (representing basis points), so a feeNumerator above 10,000 implies a royalty greater than 100% of the sale price. royaltyInfo would return a royalty amount larger than salePrice. Marketplaces that blindly forward the returned amount will either revert (if the buyer has insufficient funds) or silently under-pay (if they cap the transfer). Either way, the sale breaks or the creator receives incorrect amounts. A misconfigured numerator can permanently break secondary-market trading for the entire collection.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract BoundedRoyalty is ERC721, ERC2981, Ownable {
    uint96 public constant MAX_ROYALTY_BPS = 1000; // 10% hard cap

    constructor() ERC721("BoundedNFT", "BNFT") Ownable(msg.sender) {}

    function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
        require(receiver != address(0), "Zero address receiver");
        require(feeNumerator <= MAX_ROYALTY_BPS, "Royalty exceeds max");
        _setDefaultRoyalty(receiver, feeNumerator);
    }
}

Detection Tips


4. Wrapped NFT Royalty Bypass

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";

// Legitimate collection with royalties
contract RoyaltyNFT is ERC721, ERC2981 {
    constructor() ERC721("RoyaltyNFT", "RNFT") {
        _setDefaultRoyalty(msg.sender, 750); // 7.5%
    }
    function mint(address to, uint256 id) external { _mint(to, id); }
}

// Wrapper: holder deposits RoyaltyNFT, receives a wrapper token
// Wrapper is then traded freely with zero royalty obligations
contract WrappedNFT is ERC721 {
    RoyaltyNFT public immutable underlying;
    constructor(address _underlying) ERC721("WrappedNFT", "WNFT") {
        underlying = RoyaltyNFT(_underlying);
    }
    function wrap(uint256 tokenId) external {
        underlying.transferFrom(msg.sender, address(this), tokenId);
        _mint(msg.sender, tokenId);
    }
    function unwrap(uint256 tokenId) external {
        require(ownerOf(tokenId) == msg.sender, "Not owner");
        _burn(tokenId);
        underlying.transferFrom(address(this), msg.sender, tokenId);
    }
}

Explanation

Once a token is locked inside a wrapper contract, secondary-market trades move wrapper tokens rather than the underlying NFT. The wrapper has no royalty logic, so buyers and sellers pay zero creator fees. Because the underlying NFT never moves during wrapper-token transfers, royaltyInfo on the original contract is never consulted. This is an economic attack that requires no Solidity exploit—any user can deploy a wrapper and undercut royalties. Mitigation must occur at the transfer-hook level of the original contract.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract AntiWrapNFT is ERC721, ERC2981, Ownable {
    mapping(address => bool) public allowedReceivers;

    constructor() ERC721("AntiWrapNFT", "AWNFT") Ownable(msg.sender) {
        _setDefaultRoyalty(msg.sender, 750);
    }

    function setAllowedReceiver(address account, bool allowed) external onlyOwner {
        allowedReceivers[account] = allowed;
    }

    function _update(address to, uint256 tokenId, address auth)
        internal override returns (address)
    {
        // Reject transfers to non-EOA contracts not on the allow-list
        if (to.code.length > 0 && !allowedReceivers[to]) {
            revert("Transfers to unapproved contracts blocked");
        }
        return super._update(to, tokenId, auth);
    }

    function mint(address to, uint256 id) external onlyOwner { _mint(to, id); }
}

Detection Tips


5. Royalty Receiver Not Updateable

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";

contract ImmutableRoyalty is ERC721, ERC2981 {
    // Receiver baked in at deployment — no setter function
    constructor(address creator) ERC721("ImmutableNFT", "IMFT") {
        _setDefaultRoyalty(creator, 500);
    }
    function mint(address to, uint256 id) external { _mint(to, id); }
}

Explanation

Hardcoding the royalty receiver at deployment with no update path creates a single point of failure. If the creator's wallet is compromised, phished, or lost, all future royalty payments flow permanently to an attacker or into an inaccessible wallet. There is no recovery mechanism. Collections with long lifespans—generative art, gaming assets—need to be able to redirect royalties to a multisig, a DAO treasury, or a replacement wallet without redeploying the contract.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";

contract UpdateableRoyalty is ERC721, ERC2981, Ownable2Step {
    event RoyaltyUpdated(address indexed newReceiver, uint96 newFee);

    constructor(address creator) ERC721("UpdateableNFT", "UNFT") Ownable(msg.sender) {
        require(creator != address(0), "Zero address");
        _setDefaultRoyalty(creator, 500);
    }

    function updateRoyalty(address newReceiver, uint96 newFee) external onlyOwner {
        require(newReceiver != address(0), "Zero address");
        require(newFee <= 1000, "Exceeds 10% cap");
        _setDefaultRoyalty(newReceiver, newFee);
        emit RoyaltyUpdated(newReceiver, newFee);
    }

    function mint(address to, uint256 id) external onlyOwner { _mint(to, id); }
}

Detection Tips


6. Royalty Info Manipulation via Re-Initialization

Vulnerable Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC2981/ERC2981Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract UpgradeableNFT is Initializable, ERC721Upgradeable, ERC2981Upgradeable, OwnableUpgradeable {
    // Missing initializer modifier — can be called multiple times
    function initialize(address creator, uint96 fee) public {
        __ERC721_init("UpgradeableNFT", "UNFT");
        __ERC2981_init();
        __Ownable_init(msg.sender);
        _setDefaultRoyalty(creator, fee);
    }
}

Explanation

Upgradeable contracts that expose an initialize function without the initializer or reinitializer modifier can be called repeatedly by anyone. An attacker who calls initialize a second time can overwrite the royalty receiver and fee to values they control, redirecting all future royalty income. Because royalty storage is not separately guarded, re-initialization is equivalent to a full royalty hijack. This is structurally similar to the Parity wallet initialization bug and has appeared in several NFT contracts audited post-deployment.

Fixed Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC2981/ERC2981Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract SecureUpgradeableNFT is
    Initializable,
    ERC721Upgradeable,
    ERC2981Upgradeable,
    OwnableUpgradeable
{
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() { _disableInitializers(); }

    function initialize(address creator, uint96 fee) public initializer {
        require(creator != address(0), "Zero address");
        require(fee <= 1000, "Exceeds 10% cap");
        __ERC721_init("SecureUpgradeableNFT", "SUNFT");
        __ERC2981_init();
        __Ownable_init(msg.sender);
        _setDefaultRoyalty(creator, fee);
    }

    function updateRoyalty(address newReceiver, uint96 newFee) external onlyOwner {
        require(newReceiver != address(0), "Zero address");
        require(newFee <= 1000, "Exceeds 10% cap");
        _setDefaultRoyalty(newReceiver, newFee);
    }
}

Detection Tips


Protecting Your NFT Collection

EIP-2981 royalties sit at the intersection of economic design and smart contract security. Mistakes in royalty implementations are silent—funds drain, payments route to dead addresses, and wrapping exploits unfold without touching a single critical function. The six vulnerability classes above span everything from advisory-only enforcement to re-initialization hijacks, and all of them are detectable with automated analysis before deployment.

ContractScan scans NFT contracts for all of these patterns—zero-address receivers, uncapped fee numerators, missing initializer guards, and missing transfer hooks—before your collection goes live. Run a free scan at contractscan.io and get a detailed report of every royalty risk in your contract alongside remediation guidance.


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 →