NFT protocols are architecturally more complex than they appear. On the surface, an NFT collection is just a token contract with a minting function. In practice, it is a composition of token approval mechanics, off-chain signature infrastructure, optional on-chain randomness, royalty signaling, and — in lending contexts — a price oracle dependency that creates its own attack surface. Each layer introduces a distinct class of vulnerability, and they compound when combined.
This article treats each layer as a dedicated threat model. Code examples are written in Solidity ^0.8.20 unless otherwise noted.
1. ERC-721 and ERC-1155 Approval Exploits
The setApprovalForAll Blast Radius
The most operationally significant difference between ERC-721 and ERC-1155 at the approval level is scope. ERC-721’s per-token approval model gives collectors control over individual assets — a holder can approve one token for sale without exposing the rest of the collection. ERC-1155 takes a different approach. ERC-1155’s setApprovalForAll grants operator control over the full contract, which is more efficient but requires users to trust the operator with every token ID in the contract.
The blast radius divergence is the core risk. A compromised marketplace contract or a malicious operator approval can drain every token type a holder owns in one transaction, whereas ERC-721’s per-token approval limits blast radius.
Instead of approving specific amounts, you set an operator to approved or not approved via setApprovalForAll. Reading the current status can be done via isApprovedForAll. It is an all-or-nothing operation — you cannot define how many tokens to approve, or even which token class.
The attack vector does not require a protocol-level bug. Malicious NFT minting and marketplace approval traps abuse callbacks or setApprovalForAll to seize assets. Phishing frontends, counterfeit collection contracts, and deceptive wallet-connect interfaces are all viable delivery mechanisms.
Vulnerable Pattern: Stale Approval Not Revoked on Transfer
ERC-721’s approve() sets a per-token operator. The standard revokes this approval on transfer, but only for the single-token approval, not any operator set via setApprovalForAll. The following pattern is common and dangerous:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract VulnerableNFT is ERC721 {
constructor() ERC721("VulnNFT", "VNFT") {}
function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}
// ❌ VULNERABLE: Does NOT revoke setApprovalForAll before transfer.
// A previous operator approval persists even after ownership changes.
// The new owner may not know they have a pre-existing operator.
function transferFrom(
address from,
address to,
uint256 tokenId
) public override {
super.transferFrom(from, to, tokenId);
// Stale setApprovalForAll from `from` still active for operator.
// New owner `to` is unaware of the operator's access.
}
}
Secure Pattern: Explicit Operator Revocation
Implement a mechanism to revoke prior approvals automatically upon transfer. This can be done within the transfer function to ensure that when an NFT is transferred, all prior approvals are invalidated.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract HardenedNFT is ERC721 {
// Track per-token operators to enable revocation on transfer
mapping(uint256 => address) private _tokenOperators;
constructor() ERC721("HardenedNFT", "HNFT") {}
function approve(address to, uint256 tokenId) public override {
_tokenOperators[tokenId] = to;
super.approve(to, tokenId);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId,
uint256 batchSize
) internal override {
super._beforeTokenTransfer(from, to, tokenId, batchSize);
// ✅ Revoke single-token approval on every transfer
if (_tokenOperators[tokenId] != address(0)) {
delete _tokenOperators[tokenId];
// Force-clear via ERC-721 internal approval storage
_approve(address(0), tokenId);
}
}
}
Security-focused projects sometimes deploy separate ERC-1155 contracts per token category to restore isolation, partially defeating the contract-consolidation advantage but meaningfully reducing operator blast radius.
2. Marketplace Signature Security and Bid Replay
The Off-Chain Signature Model
NFT marketplaces rely heavily on off-chain signed orders to avoid requiring users to submit on-chain transactions for every listing. The security of this model depends entirely on how the contract validates and invalidates signatures. When validation is weak, every valid signature becomes a permanent exploit window.
A replay attack happens when a perfectly valid digital signature (or transaction) is intercepted and resubmitted, tricking the contract into executing the same action multiple times — or executing it on a completely different chain — without the original signer ever approving the repeated action.
The OpenSea Stale Listing Incident
You list an NFT for 1 ETH, then cancel the listing when the floor price rises to 5 ETH. If the marketplace contract doesn’t properly invalidate your old signature, an attacker could replay it, forcing the sale at the old 1 ETH price. In the 2022 OpenSea incident, users migrating to a new contract version didn’t realize their old signatures remained valid, allowing attackers to purchase valuable NFTs at outdated listing prices.
Vulnerable Signature Verification
The vulnerability resides in the signature verification process, potentially leading to replay attacks. An attacker could reuse the same signature multiple times for personal gain. The createToken() function pattern allows users to create NFT tokens, given the NFT calldata has been signed by the project owner. However, there is no validation to check if the signature has been used or has expired.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// ❌ VULNERABLE: No nonce, no expiry, no chain ID binding.
contract VulnerableMarketplace is ERC721 {
using ECDSA for bytes32;
address public signer;
constructor(address _signer) ERC721("VulnNFT", "VNFT") {
signer = _signer;
}
function mintWithSignature(
address to,
uint256 tokenId,
bytes calldata signature
) external {
// Hash does NOT include: nonce, expiry, chainId
bytes32 hash = keccak256(abi.encodePacked(to, tokenId));
bytes32 ethHash = hash.toEthSignedMessageHash();
address recovered = ethHash.recover(signature);
require(recovered == signer, "Invalid signature");
// ❌ Signature can be replayed indefinitely on same or other chains
_mint(to, tokenId);
}
}
Solidity’s ecrecover() function returns either the signing address or 0 if the signature is invalid; the return value of ecrecover() must be checked to detect invalid signatures.
Hardened EIP-712 Implementation with Nonces and Deadlines
The industry-standard defense against all replay attack variants is: EIP-712 + Nonces + Deadline. OpenZeppelin provides battle-tested implementations of all three.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract HardenedMarketplace is ERC721, EIP712 {
using ECDSA for bytes32;
address public signer;
// ✅ Per-user nonce tracking prevents replay
mapping(address => uint256) public nonces;
// ✅ EIP-712 typed data hash
bytes32 public constant MINT_TYPEHASH =
keccak256(
"MintOrder(address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
);
constructor(address _signer)
ERC721("HardenedNFT", "HNFT")
EIP712("HardenedMarketplace", "1")
{
signer = _signer;
}
function mintWithSignature(
address to,
uint256 tokenId,
uint256 deadline,
bytes calldata signature
) external {
// ✅ Deadline check — signature expires
require(block.timestamp <= deadline, "Signature expired");
uint256 currentNonce = nonces[to];
// ✅ EIP-712 domain-separated hash — chain-specific
bytes32 structHash = keccak256(
abi.encode(MINT_TYPEHASH, to, tokenId, currentNonce, deadline)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = digest.recover(signature);
require(recovered == signer, "Invalid signature");
// ✅ Increment nonce before mint — prevents replay
unchecked {
nonces[to]++;
}
_mint(to, tokenId);
}
}
Implementing EIP-712 to identify and place checks on the chainId can prevent cross-chain replay attacks by ensuring signatures are valid only for a specific network.
Bid Replay on Price Drops
Bid replay is a directional variant: an attacker saves a signed bid placed at peak price and replays it after the floor collapses, purchasing at a now-inflated offer relative to market. Marketplace contracts should implement nonce-based signature tracking, include expiration timestamps in signatures, require users to explicitly cancel all listings before price changes, and verify the marketplace invalidates signatures on cancellation.
3. Royalty Enforcement Bypasses
EIP-2981: Signaling Without Enforcement
There is a well-known royalty standard, ERC-2981, but it only defines how to pass royalty information — it doesn’t enforce payment. Actual payments and even the use of the standard are entirely up to the marketplaces.
One of the key issues with royalties is that token standards like ERC-721 and ERC-1155 do not include any royalty mechanism at all. As a result, marketplaces implement royalties in ways that are convenient for them, which doesn’t guarantee compatibility between platforms or enforceability of royalty payments. This leads to a situation where content creators do not receive fair compensation for the resale of their works on the secondary market.
Since enforcement depends on the marketplace, creators are subject to the competitive incentives of platforms offering better terms to end users. There’s always the possibility of bypassing the fee — if a marketplace enforces royalties, it may lose users to other platforms that don’t, making enforcement a non-trivial decision. As a result, the incentive is often not to enforce royalties.
The Bypass via Royalty-Ignorant Transfer Wrappers
The most common royalty bypass is trivially simple: transfer the NFT through a contract or peer-to-peer mechanism that never calls royaltyInfo(). Since EIP-2981 is a view function, nothing compels a marketplace to ever call it.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
// ❌ Royalty bypass: transfers NFT without consulting EIP-2981
contract RoyaltyBypassWrapper {
function atomicSwap(
address nftContract,
uint256 tokenId,
address recipient
) external payable {
// Forwards ETH directly to current owner, skips royaltyInfo()
address owner = IERC721(nftContract).ownerOf(tokenId);
IERC721(nftContract).transferFrom(owner, recipient, tokenId);
(bool ok, ) = payable(owner).call{value: msg.value}("");
require(ok, "Transfer failed");
// Creator receives 0. Royalty entirely skipped.
}
}
The ERC-721C Operator Whitelist Approach
The ERC-721C Payment Processor always enforces royalties defined by the creator either through ERC-2981 or through manual configuration — helping avoid unpaid royalties, as long as whitelist settings are in place for marketplaces that support ERC-721C.
The core mechanism is transfer gating: the token contract overrides _beforeTokenTransfer to revert unless the operator is on an allowlist of royalty-compliant marketplaces. This creates its own risks — centralization, operator list governance, and the possibility of griefing via allowlist removal.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
// Simplified illustration of the ERC-721C operator whitelist model
contract RoyaltyEnforcedNFT is ERC721, Ownable {
mapping(address => bool) public approvedOperators;
event OperatorApproved(address indexed operator, bool approved);
constructor() ERC721("RoyaltyNFT", "RNFT") Ownable(msg.sender) {}
function setApprovedOperator(address operator, bool approved)
external
onlyOwner
{
approvedOperators[operator] = approved;
emit OperatorApproved(operator, approved);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId,
uint256 batchSize
) internal override {
super._beforeTokenTransfer(from, to, tokenId, batchSize);
// Allow: minting (from == 0) and direct owner transfer
if (from == address(0) || from == msg.sender) return;
// ✅ Revert if operator is not royalty-compliant
require(
approvedOperators[msg.sender],
"Operator not royalty-compliant"
);
}
}
4. Reentrancy via ERC-721A _safeMint
This is the most consistently underestimated reentrancy surface in the NFT ecosystem. The word “safe” in _safeMint refers to token delivery safety — preventing NFTs from being locked in contracts that cannot handle them — not execution safety for the caller contract.
How the Hook Works
ERC-721 tokens have become the backbone of the NFT ecosystem, however their implementation contains subtle security risks that developers often overlook. The ERC-721 standard includes a safety mechanism called the ERC-721Receiver hook, designed to prevent tokens from being lost when sent to contracts. However, this same mechanism introduces an external call that can be exploited through reentrancy attacks.
The _checkOnERC721Received is an external call to the receiving contract, allowing arbitrary execution.
In the context of _safeMint, the risk emerges when the function checks if the recipient is a smart contract and can handle ERC-721 tokens. If the recipient contract is malicious and has implemented the onERC721Received function, it could potentially make recursive calls back to the _safeMint function or other functions in the ERC-721 contract. This could lead to a variety of issues, including unexpected state changes or, in the worst-case scenario, draining of funds.
The HypeBears Real-World Exploit
A real-world attack transaction was reported against the HypeBears NFT contract. After investigation, it was found to be a reentrancy attack caused by the _safeMint function of ERC-721. The project had a limitation of the NFTs that an account can mint, with a map addressMinted that logs whether an account has minted.
The vulnerability highlights a critical lesson: even when attempting to follow established security patterns like checks-effects-interactions, the introduction of loops with external calls can create unexpected attack vectors. The ERC-721 standard’s safety features, while well-intentioned, can become security liabilities without proper safeguards.
Vulnerable ERC-721A Batch Mint
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Simplified ERC-721A-style vulnerable minting contract
// ❌ Classic CEI violation with _safeMint
contract VulnerableMint {
mapping(address => bool) public hasMinted;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
uint256 public constant MAX_SUPPLY = 1000;
uint256 public constant MAX_PER_WALLET = 2;
// Simulated _safeMint: increments supply, then calls receiver hook
function _safeMintInternal(address to, uint256 quantity) internal {
// State update happens in loop — but external call fires mid-loop
for (uint256 i = 0; i < quantity; i++) {
totalSupply++;
balanceOf[to]++;
// ❌ External call before hasMinted is set to true
// Attacker's onERC721Received reenters claim() here
_checkOnReceived(to);
}
}
function claim(uint256 quantity) external {
require(!hasMinted[msg.sender], "Already minted"); // ❌ Check is too early
require(quantity <= MAX_PER_WALLET, "Exceeds limit");
require(totalSupply + quantity <= MAX_SUPPLY, "Exceeds supply");
// ❌ hasMinted set AFTER _safeMint — allows reentrant second claim
_safeMintInternal(msg.sender, quantity);
hasMinted[msg.sender] = true; // Too late — already reentered
}
function _checkOnReceived(address to) internal {
if (to.code.length > 0) {
// Calls onERC721Received on `to` — attacker controls this
(bool success, ) = to.call(
abi.encodeWithSignature(
"onERC721Received(address,address,uint256,bytes)",
msg.sender, address(0), totalSupply, ""
)
);
require(success, "Receiver hook failed");
}
}
}
Attacker Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableMint {
function claim(uint256 quantity) external;
}
contract MintReentrancyAttacker {
IVulnerableMint public target;
bool private _attacking;
constructor(address _target) {
target = IVulnerableMint(_target);
}
function attack() external {
_attacking = true;
target.claim(2); // Initial claim
}
// ✅ ERC-721 receiver hook — called mid-_safeMint
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
if (_attacking) {
_attacking = false;
// ❌ Reenters claim() before hasMinted[attacker] is set
target.claim(2); // Gets 2 MORE tokens beyond the limit
}
return this.onERC721Received.selector;
}
}
Hardened Minting with CEI + ReentrancyGuard
If we check the vulnerable claim(), it lacks the Checks-Effects-Interactions pattern and the nature of _safeMint function implementation makes it vulnerable to reentrancy. This situation can be prevented by following Checks-Effects-Interactions, where canClaim can be set to false before minting happens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721A.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract HardenedMint is ERC721A, ReentrancyGuard {
uint256 public constant MAX_SUPPLY = 1000;
uint256 public constant MAX_PER_WALLET = 2;
mapping(address => bool) public hasMinted;
constructor() ERC721A("HardenedNFT", "HNFT") {}
function claim(uint256 quantity) external nonReentrant {
// ✅ CHECKS
require(!hasMinted[msg.sender], "Already minted");
require(quantity <= MAX_PER_WALLET, "Exceeds per-wallet limit");
require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds max supply");
// ✅ EFFECTS — state updated BEFORE external call
hasMinted[msg.sender] = true;
// ✅ INTERACTIONS — _safeMint fires onERC721Received
// nonReentrant guard blocks any reentry regardless
_safeMint(msg.sender, quantity);
}
}
For NFT contracts, there exist some implicit external function calls that could be neglected by developers. They include onERC721Received and onERC1155Received. The onERC721Received function was designed to check whether the receiver contract can handle NFTs. This function is invoked in the safeTransferFrom and _safeMint of the ERC-721 contract. Both must be treated as reentrancy triggers.
5. NFT-Collateralized Lending Risks
Protocol Architecture and Collateral Risk
NFT lending protocols accept NFTs as collateral for ETH or stablecoin loans. The theft in such protocols highlights the financial exposure tied to NFT-backed lending markets, where high-value collectibles can be temporarily locked in escrow during borrowing arrangements. Protocols enable collectors to unlock capital by pledging NFTs as collateral for loans, allowing lenders to earn interest while borrowers maintain exposure to their digital assets. But combining NFT marketplaces with complex lending contracts introduces additional layers of technical risk.
Even small errors in smart contract verification logic can create opportunities for attackers to manipulate transactions involving valuable digital collectibles.
The Sell-and-Repay Bundle Attack Pattern
Real exploits have targeted components of NFT loan systems designed to automate the sale of collateralized assets and repay outstanding debt in a single transaction. Attackers have drained NFTs across dozens of transactions before the vulnerability was contained.
The exploit pattern traces back to faulty logic within a “Purchase Bundler” function. That function is intended to bundle NFT sales with loan repayments, enabling borrowers to sell escrowed NFTs and automatically settle outstanding balances in a single transaction.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IPriceOracle {
function getFloorPrice(address collection) external view returns (uint256);
}
// Simplified NFT lending pool — illustrates key vulnerability surfaces
contract NFTLendingPool is ReentrancyGuard {
IPriceOracle public oracle;
uint256 public constant LTV = 50; // 50% loan-to-value
struct Loan {
address borrower;
address collection;
uint256 tokenId;
uint256 principal;
uint256 startTime;
}
mapping(bytes32 => Loan) public loans;
constructor(address _oracle) {
oracle = IPriceOracle(_oracle);
}
function borrow(
address collection,
uint256 tokenId
) external nonReentrant returns (bytes32 loanId) {
uint256 floorPrice = oracle.getFloorPrice(collection);
require(floorPrice > 0, "No floor price");
uint256 loanAmount = (floorPrice * LTV) / 100;
// Pull NFT into escrow
IERC721(collection).transferFrom(msg.sender, address(this), tokenId);
loanId = keccak256(
abi.encodePacked(msg.sender, collection, tokenId, block.timestamp)
);
loans[loanId] = Loan({
borrower: msg.sender,
collection: collection,
tokenId: tokenId,
principal: loanAmount,
startTime: block.timestamp
});
// ✅ State written before ETH sent
(bool ok, ) = payable(msg.sender).call{value: loanAmount}("");
require(ok, "ETH transfer failed");
}
function liquidate(bytes32 loanId) external nonReentrant {
Loan memory loan = loans[loanId];
require(loan.borrower != address(0), "Loan not found");
uint256 currentFloor = oracle.getFloorPrice(loan.collection);
// ❌ Risk: oracle reports stale or manipulated floor price
// If floor is artificially high, under-collateralized positions
// cannot be liquidated, accumulating bad debt.
require(
currentFloor < (loan.principal * 100) / LTV,
"Position still healthy"
);
delete loans[loanId];
// Transfer collateral to liquidator
IERC721(loan.collection).transferFrom(
address(this),
msg.sender,
loan.tokenId
);
}
receive() external payable {}
}
Valuation and Liquidity Risk
Market risk is a key factor in the NFT lending industry, as it can have a major impact on the value of assets used as collateral or loan repayment. This risk is related to three key factors: valuation, volatility, and liquidity. The unique nature of NFTs makes it difficult to accurately assess their value, which can create uncertainty and make it challenging to manage risk. The consensus within the industry is to estimate the worst-case value of an NFT based on the “floor price” of the collection it belongs to.
6. Floor Price Manipulation in Lending Protocols
Floor price is the primary valuation input for NFT lending protocols. It is also the most manipulable data point in the NFT ecosystem.
Wash Trading as Oracle Attack
An attacker who wants to borrow against an NFT collection at an inflated LTV has a clear incentive: push the floor price up before the oracle snapshot, borrow maximum principal, then let the floor collapse. The protocol is left holding depreciating collateral against a loan it can’t fully recover.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ❌ VULNERABLE Oracle: uses a single recent sale as floor price
// An attacker can self-trade at inflated price to set a manipulated floor
contract VulnerableFloorOracle {
mapping(address => uint256) public lastSalePrice;
// Called by marketplace on every sale settlement
function recordSale(address collection, uint256 price) external {
// ❌ Single data point, no TWAP, no volume weight, no outlier rejection
lastSalePrice[collection] = price;
}
function getFloorPrice(address collection)
external
view
returns (uint256)
{
return lastSalePrice[collection]; // Trivially manipulable
}
}
// ✅ Hardened Oracle: Time-Weighted Average Price with minimum observations
contract HardenedFloorOracle {
struct PricePoint {
uint256 price;
uint256 timestamp;
}
mapping(address => PricePoint[]) public priceHistory;
uint256 public constant TWAP_WINDOW = 24 hours;
uint256 public constant MIN_OBSERVATIONS = 10;
uint256 public constant MAX_DEVIATION_BPS = 2000; // 20%
function recordSale(address collection, uint256 price) external {
PricePoint[] storage history = priceHistory[collection];
// Outlier rejection: revert if price deviates >20% from last price
if (history.length > 0) {
uint256 lastPrice = history[history.length - 1].price;
uint256