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

  • SEC and CFTC Update Crypto FAQs: Token Buybacks and Network Upgrades Not Necessarily Securities, CFTC Allows On-Chain Record Keeping

    On September 26, the U.S. Securities and Exchange Commission's Division of Corporation Finance released an updated FAQ on September 25, clarifying that token buybacks, network upgrades, and marketing statements do not automatically make crypto assets securities. SEC staff noted that announcing a buyback plan for an operational crypto network does not, by itself, make the associated tokens investment contracts; however, if the network is not operational and the issuer promotes the buyback as a source of returns for holders, it may be a different case. The FAQ also clarified that services provided once a crypto system is operational, aimed at securing, maintaining, improving, or enhancing the system or its functions, or promoting network effects, do not constitute managerial efforts under the Howey test. Marketing existing uses of the network typically does not create profit expectations, and statements about future functionalities do not either, provided there is no promotion of profit potential. This update reiterates that conclusions will still heavily depend on specific cases and are based on the SEC's interpretative release regarding the applicability of securities laws to crypto assets issued in March this year. On the same day, the Commodity Futures Trading Commission updated its crypto FAQ, allowing futures firms and clearinghouses to invest customer funds in tokenized versions of previously permitted assets, provided they meet investment and custody requirements. CFTC staff also indicated that regulated companies may use blockchain for record keeping but must still be able to provide records if the blockchain or its block explorer is non-operational. These updates come as the CLARITY Act failed to advance in the Senate, with regulators continuing to push forward with the crypto regulatory framework based on existing laws.

  • BTC Surpasses $84,000

    Market data shows that BTC has surpassed $84,000, currently priced at $84,004, with a 24-hour decline of 0.25%. The market is experiencing significant volatility, so please ensure proper risk management.

  • BTC Falls Below $84,000

    Market data shows that BTC has fallen below $84,000, currently priced at $83,988.06, with a 24-hour increase of 0.52%. The market is experiencing significant volatility, so please ensure proper risk management.

  • ETH Falls Below $2700

    Market data shows that ETH has fallen below $2700, currently priced at $2699.7, with a 24-hour increase of 1.95%. The market is experiencing significant volatility, so please ensure proper risk management.

  • BTC Surpasses $85,000

    Market data shows that BTC has surpassed $85,000, currently priced at $85,000.02, with a 24-hour increase of 1.72%. The market is highly volatile, so please ensure proper risk management.

  • ETH Surpasses $2700

    Market data shows that ETH has surpassed $2700, currently priced at $2700.14, with a 24-hour increase of 1.23%. The market is experiencing significant fluctuations, so please ensure proper risk management.

  • Yushu Technology's Wang Xingxing: Key to Breakthrough in Embodied Intelligence Lies in Solving Millimeter-Level Error Issues

    On September 25, the 5th Global Digital Trade Expo was held in Hangzhou, where Wang Xingxing, founder of Yushu Technology, delivered a keynote speech titled "From Machinery to Intelligence - The Evolutionary Theory of Embodied Future." Wang stated that the embodied intelligence industry may soon experience a critical breakthrough similar to that of ChatGPT. He believes that when robots can complete approximately 80% of tasks through voice interaction and embodied intelligence capabilities in about 80% of unfamiliar environments, the industry will enter a critical phase of large-scale application. He pointed out that the ability for robots to understand and execute specific tasks based on voice commands has already made breakthroughs last year, but the industry still faces a core technological bottleneck, namely the precise matching issue between artificial intelligence models and the real physical world. Wang noted that currently, robots still have a few millimeters of error during actual operations, which limits their stability and reliability in complex environments. "In the future, whoever can solve this problem will fundamentally resolve the issues with robots."

  • Swissquote Analyst Warns AI Narrative is a Core Pillar of US Stocks, Potential Break Could Trigger Significant Correction

    On September 25, Ipek Ozkardeskaya, a senior analyst at Swissquote Bank, stated that broad market indices and retirement funds are now deeply tied to the AI wave, with technology stocks accounting for about 40% of the S&P 500 index. She pointed out that the market capitalization weight of just three chip manufacturers makes up over 25% of the MSCI Emerging Markets Index. Ozkardeskaya indicated that AI has become the 'core pillar' of the market, and this pillar 'must not show any cracks.' She believes that, in the short term, the US stock market will continue to be supported by seasonal factors, and the current market uptrend may extend until the end of the year. However, she also warned that the worst-case scenario would be a shake in the investment logic surrounding AI, which could undermine market confidence in the AI narrative, potentially triggering a significant market correction.

  • U.S. Stock Index Futures Turn Positive; Chip Stocks Rally in After-Hours Trading

    On September 25, U.S. stock index futures rose into positive territory, with Nasdaq futures up 0.36%. In after-hours trading, storage and semiconductor stocks saw widespread gains, with AMD, Intel, and SanDisk all rising by 2%.

  • NEAR Partners with Ondo to Launch 20 Tokenized US Stocks and ETFs

    On September 25, according to Cryptonews, NEAR Protocol and Ondo Finance have launched trading for tokenized US stocks and ETFs on near.com, with an initial offering of 20 assets including Nvidia, Tesla, Apple, Microsoft, Amazon, as well as SPY and QQQ. Eligible users can deposit using over 30 supported stablecoins or other crypto assets, with NEAR Intents serving as the cross-chain distribution layer, allowing similar assets to be routed to connected wallets and DeFi protocols in the future. Purchases are settled in USDon, which is backed 1:1 by US dollars in brokerage accounts, and completed via atomic swaps. This product is not available to US persons; the overall Ondo platform has launched over 100 assets, with NEAR initially offering only one-fifth of that.