Cointime

Download App
iOS & Android

How to Secure NFTs: Part Two - NFT Smart Contract Security

Validated Project

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 the safeTransferFrom() function in the NFT contract.
  • The safeTransferFrom() function will further trigger the onERC721Received() callback on the attacker contract, where the attacker contract calls withdrawBid().
  • 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 the pendingWithdrawals 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 and onERC721Received 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.

https://www.certik.com/resources/blog/1Kl3XHKDdzIZvW0dyJLuSu-how-to-secure-nfts-part-two-nft-smart-contract-security

Comments

All Comments

Recommended for you

  • U.S. July Nonfarm Payrolls Fall by 23,000, Missing Market Expectations

    On August 7, U.S. nonfarm payrolls decreased by 23,000 in July, compared with market expectations of an increase of 80,000, and the previous value was an increase of 57,000.

  • US May and June Nonfarm Payroll Additions Revised Down by 103,000 Combined

    On August 7, the US Bureau of Labor Statistics: May nonfarm payroll additions were revised down from 129,000 to 63,000; June nonfarm payroll additions were revised down from 57,000 to 20,000. After the revisions, the combined additions for May and June were 103,000 lower than previously reported.

  • U.S. Rate Futures Market Sees Lower Odds of Fed September Hike

    On August 7, the probability of a Fed rate hike in September as priced by U.S. interest rate futures declined.

  • New York Gold Futures Top $4,400 per Ounce

    New York gold futures topped $4,400 per ounce, up 2.36% on the day.

  • Japan Finance Minister: FX Market Affected by Moves Not Driven by Actual Demand

    Japanese Finance Minister Satsuki Katayama said she and U.S. Treasury Secretary Bessent agreed that the foreign exchange market has been affected by moves not driven by actual demand.

  • BTC Breaks Through $65,000

    Market data shows BTC has broken through $65,000, currently reported at $65,007.44, with a 24-hour increase of 0.6%. The market is highly volatile, please exercise risk control.

  • Brent Crude Drops 2.00% Intraday to $81.07/Barrel

    Brent crude oil fell 2.00% during the day, now at $81.07 per barrel. (Jin Shi)

  • Trump: Data Centers May Be More Important Than Oil

    August 7 news, U.S. President Trump said in an interview with Punchbowl News, "I saw the other day that Texas seems to be opposed to building data centers. I think that's a mistake. I'm not taking a position—I just think it's a mistake, because there are other communities that want to build data centers. When a community is willing to accept data centers, it means a lot of money will flow into that community. I don't think they're ugly. Some of the data centers I've seen are the most incredible buildings I've ever seen. They are very important to the economy. If Texas says no to data centers, that's a mistake, because data centers may be more important than oil."

  • Trump to Meet with Mining Executives

    On August 7, according to CCTV International News, US President Trump will convene executives from some of the world's largest mining companies at the US State Department on August 7 local time, in an effort to take action to 'secure critical mineral supplies for the US and its allies.' Reuters reported that the US urgently needs critical minerals to replenish weapons inventories depleted during the war against Iran. During the more than five-month war with Iran, the US military expended large quantities of precision-guided missiles and air defense interceptors. US defense officials and lawmakers have warned that given existing production capacity constraints, replenishing some stockpiles could take years—although the Trump administration has denied reports of a so-called 'severe shortage of ammunition stockpiles.' According to Pentagon officials and defense companies, supplies of minerals such as rare earths, tungsten, germanium, and scandium are essential for manufacturing precision-guided missiles, fighter jets, armored vehicles, infrared sensors, and other advanced weapons systems. Expected attendees include industry giants such as global mining giant Rio Tinto Group, Australia's BHP, US Freeport-McMoRan, US Mountain Pass Materials, US Rare Earths, US Energy Fuels, and Canada's Metals Company. According to sources, the Trump administration plans to announce multiple deals and memorandums of understanding.

  • US Regulators Systematically Review Chinese AI Firms' Third-Country Computing Power Leasing

    August 7 news, according to Bloomberg, people familiar with the matter revealed that the U.S. government department responsible for investigating chip export control violations is reviewing Chinese AI companies' leasing of computing power in third countries to obtain Nvidia advanced chips.