As outlined in Part One, NFT projects can be vulnerable to a number of different attack vectors, including phishing, private key compromises, and exit scams. Smart contract security is another area of great significance to NFT projects. To some extent, it is more “controllable” for projects compared to scams, phishing attacks, and users' private keys being compromised.
Smart contracts must ensure the safety of the users' assets while maintaining internal logical correctness so that the contract works as intended. When auditing NFT-related smart contracts, the first step is to understand the project's design as a whole to ensure that the code implementation aligns with the overall intention.
Additionally, the auditing process should check for the common vulnerabilities and zero-day vulnerabilities. Finally, manual testing (unitest/fuzzing) and auto-testing tools also play an important role as the final guardrail to ensure the safety of the contracts.
In the following section, we will explore some common vulnerabilities that are commonly encountered during NFT smart contract audits.
NFT Contract Risks
Two of the most common issues that are found in audits of NFT projects are reentrancy vulnerabiltiies and access validations for sensitive functions.
Many contracts include callback checks to ensure that receiving contracts are eligible to receive NFT transfers before processing such transfers. There are a number of opportunities for external contracts to take advantage of these callback checks to execute malicious code.
Minting of NFTs is a sensitive action usually controlled by one or more privileged accounts. This is done to ensure that the rarity and value of each NFT is preserved. For this reason, it is important to ensure that user verification ensures that only the right users are allowed to mint tokens. Any issues in the implementation will result in unintended restricted access to the minting of NFTs.
Reentrancy Exploits of Unsafe Callbacks
The most common NFT standards are ERC-721 and ERC-1155. OpenZeppelin has implemented corresponding template contracts that are commonly used by the community. These contracts incorporate functions such as _safeTransfer
, _safeMint
, _mint
, _safeTransferFrom
, _mintBatch
and _safeBatchTransferFrom
that will invoke the receiver contract through the callback functions onERC721Received
, onERC1155Received
, and onERC1155BatchReceived
to ensure that the tokens can be received by the receiver. However, this can potentially create a security loophole. An attacker can perform a reentrancy call inside the onERC721Received
, onERC1155Received
, or onERC1155BatchReceived
callback.
The following code snippet is from a vulnerable NFT contract that only allows each whitelisted user to mint one NFT:
function mintNFT(bytes memory _signature) public payable {
require(mintActive, 'Not active');
require(mintPrice <= msg.value, "Insufficient payable value");
require(totalSupply().add(1).add(partnerMintAmount) <= TOTAL_NFT, "Can't mint more than 10000");
require(whitelist[msg.sender], "Not whitelisted User")
require(!addressMinted[msg.sender], "Address has minted");
_safeMint(msg.sender, totalSupply() + 1);
addressMinted[msg.sender] = true;
}
In the above code, the _safeMint
call is invoked before the state addressMinted[msg.sender]
is set as true
. By performing a reentrancy attack on the mintNFT
function, the attacker can bypass the check require(!addressMinted[msg.sender], "Address has minted")
to mint as many tokens as they like. The recommended mitigation is to follow the check-effect-interaction coding pattern or apply reentrancy guardrails like nonReentrant
modifiers.
Insufficient Signature Validation in the Token Minting Process
The following code snippet is part of the implementation of an NFT contract which has a bug in the minting process that allows attackers to mint as many tokens as they want.
The NFT project is designed to maintain the uniqueness of its NFT tokens and sets corresponding restrictions by verifying the signature. As per the design, users can call the mintToken()
function to pay ETH to mint NFTs, and the minting transaction needs to be first approved by the “minter role”, which is illustrated in the verification implementation of the _verifyVoucher function
.
Note: The following code is simplified for better illustration:
function mintTokens(uint256 tokenCount_, VoucherParams memory params_) external payable whenNotPaused {
require(tokenCount_ * params_.pricePerToken == msg.value, "Not enough ETH Received");
require(
_verifyVoucher(
abi.encode(
keccak256(
"Voucher(address buyer,uint256 issueTime,uint256 expirationDuration,uint256 pricePerToken,uint256 nonce,string functionABI)"
),
_msgSender(),
params_.issueTime,
params_.expirationDuration,
params_.pricePerToken,
params_.nonce,
keccak256(bytes("mintTokens(uint256,VoucherParams)"))
),
params_,
VOUCHER_SIGNER_ROLE
),
"Verification Failed"
);
for (uint256 i = 1; i <= tokenCount_; i++) {
_safeMint(_msgSender(), _tokenIdCounter.current());
_tokenIdCounter.increment();
}
}
function _verifyVoucher(
bytes memory stuff_,
VoucherParams memory params_,
bytes32 role_
) internal returns (bool) {
require(!_voucherNonces[params_.nonce], "The nonce has been used");
_voucherNonces[params_.nonce] = true;
require(
params_.issueTime <= block.timestamp && block.timestamp <= (params_.issueTime + params_.expirationDuration),
"Not correct time to mint"
);
// Verify Signer
require(hasRole(role_, ECDSA.recover(_hashTypedDataV4(keccak256(stuff_)), params_.signature)), "Not minter roles");
return true;
}
The above code seems to look good as the signature has been verified by the ECDSA.recover
function. However, the parameter tokenCount_
is missed in the verification, meaning once the verification of VoucherParams
passed, users can actually mint as many tokens as they want.
Replay Attacks Due to Weak Signature Verification
The next example also contains a vulnerability in the signature verification process. However, in contrast from the above case, this vulnerability in the signature verification could lead to potential replay attacks, meaning an attacker can repeatedly use the signature for profit.
As in the below code snippet, the function createToken()
allows users to create NFT tokens only if the created NFT calldata is to be signed by the project owner. However, lacking validation on whether the signature has been used or expired. Therefore, the attacker can perform a replay attack with the same calldata to keep creating tokens.
function createToken(NFTMint calldata nft) external
{
require( _owner = _verifyNFT(nft), "Created NFT is not verified by owner");
uint256 tokenID = tokenCounter;
tokenCounter++;
_mint(nft.issuer, tokenID, nft.totalSupply, ""); // Assign fractional tokens to the issuer
_setTokenURI(
tokenID,
nft.deedNo,
nft.assetID,
nft.issuerID,
nft.projectID
);
details[tokenID].tokenID = tokenID;
details[tokenID].issuer = nft.issuer;
details[tokenID].tokens = nft.totalSupply;
emit CreatedNFT(tokenID, nft.issuer);
}
function _verifyNFT(NFTMint calldata nft)
public
view
returns (address)
{
bytes32 digest = _hashNFT(nft);
return ECDSA.recover(digest, nft.signature);
}
The exact solution may rely on the project design and intention. One proposed solution is to add a nonce in the signature or use a mapping to record if the signature has been used or not.
It is also worth mentioning that, signatures could also be vulnerable to cross-chain replay attacks. As there is no distinction between the different blockchains supported, transactions originally intended to be executed on one network could also potentially be executed on another network through a replay attack. A potential solution for cross-chain replay attacks is to enforce EIP712 to identify and place checks on the chainId to ensure that signatures are only valid for one specific network.
Marketplace contracts NFTs are usually associated with a marketplace contract, where NFTs are bought and sold. Marketplaces are integral to user interaction with NFTs since most of the transactions related to NFTs reside on marketplaces and they are also integral to determining the price and value of these NFTs. As a result of the volume of NFTs transacted across marketplaces, they are prime targets for hackers, and therefore crucial that the smart contract code is secure.
Reentrancy Attacks on the Bidding Process
Marketplace allow users to bid for the NFTs and trade them for other tokens. Marketplace contracts commonly provide a service for users to bid for certain NFTs, and also for NFT owners to transfer ownership of their NFTs. The below marketplace implementation contains a reentrancy attack vector that allows the attacker to drain assets from the contract.
In this example, the vulnerable marketplace contract designed the following functions for buyers and NFT owners to trade NFTs:
enterBid()
: Bidders can enter bids
acceptBid()
: NFT owners can accept the bid and exchange the NFT for ETH
withdrawBid()
: Buyers can withdraw a bid and bidders can get a refund
withdrawPendingFunds()
: Withdraw the pending ETH from an NFT transaction
Note: The following code is simplified for better illustration:
function enterBid(uint _id) external payable {
require(NFT.ownerOf(_id) != address(0x0), "Cannot bid on NFT assigned to the 0x0 address.");
require(msg.value > 0, "Must offer a nonzero amount for bidding.");
Bid memory existingBid = bids[_id];
require(msg.value > existingBid.value, "A higher bid has already been made for this NFT.");
// Refund the existing bid to the original bidder and overwrite with the higher bid.
if (existingBid.value > 0) {
pendingWithdrawals[existingBid.bidder] += existingBid.value;
}
bids[_id] = Bid(msg.sender, msg.value);
}
function acceptBid(uint _id, uint _minPrice) external onlyNFTOwner(_id) {
Bid memory existingBid = bids[_id];
require(existingBid.value > 0, "Cannot accept a 0 bid.");
require(existingBid.value >= _minPrice, "Existing bid is lower than the specified _minPrice.");
NFT.safeTransferFrom(msg.sender, existingBid.bidder, _id);
delete bids[_id];
pendingWithdrawals[msg.sender] += existingBid.value;
}
function withdrawBid(uint _id) external nonReentrant {
require(bids[_id].bidder == msg.sender, "Cannot withdraw a bid not made by the sender.");
uint amount = bids[_id].value;
Address.sendValue(payable(msg.sender), amount);
delete bids[_id];
}
function withdrawPendingFunds() external nonReentrant {
Address.sendValue(payable(msg.sender), pendingWithdrawals[msg.sender]);
delete pendingWithdrawals[msg.sender];
}
The issue is that the bids[]
record is deleted after the external call of the safeTransferFrom()
and Address.sendValue()
functions. Therefore, the attacker is able to perform a reentrancy attack (by either onERC721Received()
callback or native token transfer fallback) and steal the funds in the contract.
The attacker can trigger acceptBid()
and withdrawBid()
in a single transaction. This results in non-payment while allowing them to drain assets from the contract. The exact attack flow is as follows.
- The attacker first buys/borrows an NFT token
- The attacker creates a high bid for the NFT
- Since the attacker is also the owner of the token, the attacker is able to call
acceptBid()
, which triggers thesafeTransferFrom()
function in the NFT contract.
- The
safeTransferFrom()
function will further trigger theonERC721Received()
callback on the attacker contract, where the attacker contract callswithdrawBid()
.
- Since the record of the bid is not deleted yet, the
withdrawBid()
will succeed and send ETH to the attacker.
- Finally, when the
acceptBid()
function is fulfilled, the contract with increase thependingWithdrawals
records of the attacker, which can withdraw ETH later.
The issue lies in the reentrancy loophole, where the attacker can trade an NFT with themself without paying, yet cheating contracts to send assets to the attacker.
This is a very common vulnerability found during auditing. Projects should follow the check-effects-interaction pattern to avoid reentrancy attacks. It is also worth mentioning that the single nonReentrant
modifier on the withdrawBid()
function cannot prevent a “cross-function” reentrancy attack. Therefore, it is also recommended to add the nonReentrant modifier to all the linked functions accordingly.
Incorrect Price Calculation Logic
Another important consideration relates to the price calculation of certain NFTs. The following example is adapted from a real-world exploit that led to over 100 NFTs being stolen. The exploit stems from a simple design flaw in the price calculation, which allows anyone to buy ERC721 NFTs without paying. Below is the vulnerable code snippet:
The Buyer Contract:
function buyItem(
address _nftAddress,
uint256 _tokenId,
address _owner,
uint256 _quantity,
uint256 _pricePerItem
) external {
(, uint256 pricePerItem,) = marketplace.listings(_nftAddress, _tokenId, _owner);
require(pricePerItem == _pricePerItem, "pricePerItem changed!");
uint256 totalPrice = _pricePerItem * _quantity;
IERC20(marketplace.paymentToken()).safeTransferFrom(msg.sender, address(this), totalPrice);
IERC20(marketplace.paymentToken()).safeApprove(address(marketplace), totalPrice);
...
}
The Marketplace Contract:
function buyItem(
address _nftAddress,
uint256 _tokenId,
address _owner,
uint256 _quantity
)
external
nonReentrant
isListed(_nftAddress, _tokenId, _owner)
validListing(_nftAddress, _tokenId, _owner)
{
require(_msgSender() != _owner, "Cannot buy your own item");
Listing memory listedItem = listings[_nftAddress][_tokenId][_owner];
require(listedItem.quantity >= _quantity, "not enough quantity");
// Transfer NFT to buyer
if (IERC165(_nftAddress).supportsInterface(INTERFACE_ID_ERC721)) {
IERC721(_nftAddress).safeTransferFrom(_owner, _msgSender(), _tokenId);
} else {
IERC1155(_nftAddress).safeTransferFrom(_owner, _msgSender(), _tokenId, _quantity, bytes(""));
}
...
}
The buyItem()
in the Buyer contract will collect fees based on quantity and invoke buyItem()
in the Marketplace contract for NFT distribution.
The function buyItem()
in the Marketplace contract allows users to buy NFT tokens under both ERC721 and ERC1155 standards, but in this case the logic is not properly implemented. Hence, anyone can buy ERC721 for free by passing the value 0
into the quantity
parameter.
Although this is an obvious bug, it can also serve as a reminder to be careful with the design and properly handle each case to avoid bugs due to protocol incompatibilities.
Conclusion: Common Attack Vectors
In summary, NFT smart contract code can be susceptible to many of the same vulnerabilities as other smart contracts. There are certain areas that are related specifically to the operations of non-fungible tokens as well as the NFT contracts themselves. The following is a non-exhaustive list of possible issues outlined that could impact NFT contracts.
NFT Operations:
- Unlimited Token Approval: Granting unlimited token approval to malicious contracts allows the operator of that contract to drain compromised wallets of all their assets.
- Private Key Compromises: An NFT is only as secure as the private key guarding it. Users and projects should safeguard their private keys, avoid sharing them, and be on the lookout for possible vulnerabilities arising from third-party tools such as Profanity.
- Phishing: Scammers use phishing techniques to coerce users into interacting with malicious contracts or revealing their private keys.
- Social Media Platform Hacks: Bad actors target projects' Discord or Twitter accounts to spread malicious disinformation.
NFT Smart Contracts:
- Reentrancy attacks associated with ERC721 operations: Attackers can exploit
onERC721Received
andonERC721Received
function callbacks described above to launch a reentrancy attack.
- Inflate rewards/claim rewards by flash loaning NFTs: Attackers may borrow NFT using flash loans to inflate the rewards disbursed to holders.
- Incorrect price calculation: Attackers may manipulate the price of NFTs by inputting spoofed data.
- Front-running: The attacker may create MEV bots to front run offers and win auctions.
- Signature Verification: Attacker can exploit vulnerabilites in signature verification to perform a replay attack or bypass certain verification processes.
All Comments