NFT

ERC721 and ERC20 are two different token standards on the Ethereum blockchain. Unlike ERC20, ERC721 is a non-fungible token (NFT) that allows each token to have unique properties and value. This makes it suitable for creating digital artwork, game items, and other unique assets. The lifecycle of an NFT typically includes issuance, circulation, and destruction phases. Compared to the issuance mechanism of ERC20 tokens, NFT issuance has some unique considerations, with security issues primarily focused on the issuance process.

  • To ensure that no one can obtain more valuable NFTs through unfair means and to provide a fair competition environment for all participants, NFTs are typically minted based on random numbers. If pseudo-random numbers are used, it can lead to predictability attacks, compromising the fairness of NFT minting.

  • The purchase of NFTs usually involves pre-sales and official sales. During the pre-sale phase, project teams often set up whitelists for users who can participate. However, even with such measures in place, there can still be issues related to signature reuse and impersonation.

The following are examples of common and more significant vulnerabilities in NFT projects.

Minting NFTs Based on Weak Randomness

Vulnerability Category: Weak Randomness.

In the NFT minting function "luckyMint", users input a number called "luckyNumber", and if it matches the pseudo-random number "randomNumber" generated on the chain, they can mint an NFT. The pseudo-random number "randomNumber" is generated based on the blockhash and block.timestamp. However, since blockchain properties are public, for transactions within the same block, the obtained blockhash and block.timestamp will be the same. Malicious users can exploit smart contracts to deterministically obtain the "randomNumber", thereby fulfilling the condition for minting an NFT.

contract BadRandomness is ERC721 {
    uint256 totalSupply;
    // initialize the name and decimal of NFT
    constructor() ERC721("", ""){}
    // mint only when the entered luckyNumber is equal to a random number
    function luckyMint(uint256 luckyNumber) external {
        uint256 randomNumber = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))) % 100; // get bad random number
        require(randomNumber == luckyNumber, "Better luck next time!");
        _mint(msg.sender, totalSupply); // mint
        totalSupply++;
    }
}

Suggestion: To mitigate this attack, it is recommended to consider using a more secure random number generation mechanism. One viable solution is to implement Chainlink VRF (Verifiable Random Function).

Flash Loan Attacks in NFTs

The following function implements the token claim function. The second check condition uses alpha.balanceOf() and beta.balanceOf() to determine the caller's ownership of BAYC/MAYC NFTs. However, this approach only provides the instantaneous state of the user's ownership of the NFT, which can be manipulated using flash loans. Flash loans allow users to borrow a large amount of funds and manipulate market prices within the same transaction without providing any collateral. Attackers can exploit flash loans to borrow funds, manipulate prices, and create slippage issues. They then repay the borrowed funds and interest in the same transaction to ensure there is no loan balance at the end of the transaction.

For example, let's consider the APE Coin airdrop incident that occurred on March 17, 2022. The attack transaction involved the following steps: The attacker purchased a BAYC NFT with the ID 1060 and transferred it to the attack contract. The attacker borrowed a large amount of BYAC tokens using a flash loan. During this process, the attacker used the redeem function to obtain 5 additional BYAC NFTs with the IDs 7594, 8214, 9915, 8167, and 4755. The attacker used a total of 6 NFTs to claim the airdrop. The NFT with ID 1060 was their initial purchase, and the remaining 5 were acquired in the previous step. Through the airdrop, the attacker received a total of 60,564 APE tokens as a reward. The attacker needed to repay the borrowed BYAC tokens, so they minted BYAC NFTs to obtain the necessary BYAC tokens. In this process, they also minted their own NFT with ID 1060 because additional BYAC tokens were required to pay the flash loan fee. They then sold the remaining BYAC tokens after repaying the fee, generating 14 ETH. The attacker gained 60,564 APE tokens worth $500,000, with an attack cost of the 1060 NFT (purchased for 106 ETH) minus the 14 ETH obtained from selling the BYAC tokens.

The APE Coin airdrop mechanism has a vulnerability. Specifically, the eligibility for claiming the airdrop depends on the instantaneous state of a user's ownership of BYAC NFTs, which the attacker can manipulate by borrowing BYAC tokens through a flash loan and then redeeming them for BYAC NFTs. The attacker first borrows BYAC tokens through a flash loan, redeems them for BYAC NFTs, uses these NFTs to claim the APE airdrop, and finally mints BYAC tokens using the BYAC NFTs to repay the flash loan.

function claimTokens() external whenNotPaused {
    require(block.timestamp >= claimStartTime && block.timestamp < claimStartTime + claimDuration, "Claimable period is finished");
    require((beta.balanceOf(msg.sender) > 0 || alpha.balanceOf(msg.sender) > 0), "Nothing to claim");
    uint256 tokensToClaim;
    uint256 gammaToBeClaim;
    (tokensToClaim, gammaToBeClaim) = getClaimableTokenAmountAndGammaToClaim(msg.sender);
    for(uint256 i; i < alpha.balanceOf(msg.sender); ++i) {
        uint256 tokenId = alpha.tokenOfOwnerByIndex(msg.sender, i);
        if(!alphaClaimed[tokenId]) {
            alphaClaimed[tokenId] = true;
            emit AlphaClaimed(tokenId, msg.sender, block.timestamp);
        }
    }
    for(uint256 i; i < beta.balanceOf(msg.sender); ++i) {
        uint256 tokenId = beta.tokenOfOwnerByIndex(msg.sender, i);
        if(!betaClaimed[tokenId]) {
            betaClaimed[tokenId] = true;
            emit BetaClaimed(tokenId, msg.sender, block.timestamp);
        }
    }
    uint256 currentGammaClaimed;
    for(uint256 i; i < gamma.balanceOf(msg.sender); ++i) {
        uint256 tokenId = gamma.tokenOfOwnerByIndex(msg.sender, i);
        if(!gammaClaimed[tokenId] && currentGammaClaimed < gammaToBeClaim) {
            gammaClaimed[tokenId] = true;
            emit GammaClaimed(tokenId, msg.sender, block.timestamp);
            currentGammaClaimed++;
        }
    }
    grapesToken.safeTransfer(msg.sender, tokensToClaim);
    totalClaimed += tokensToClaim;
    emit AirDrop(msg.sender, tokensToClaim, block.timestamp);
}

Suggestion: There is a more secure way to validate a user's ownership of an NFT, which involves using an off-chain snapshot based on a Merkle tree. This approach stores the whitelist on the project's central server off the blockchain. When a user clicks on "mint" on the frontend website, the server generates a Merkle proof based on the user's wallet address. The user then sends a transaction to the smart contract carrying the Merkle proof, which is then verified on-chain by the smart contract. However, this method also has potential security risks, including signature reuse and impersonation issues.

Signature Reuse and Forgery Issues in Whitelist Verification

The following code is used to verify authorization information and issue presale NFTs. This code has two security issues: signature reuse and signature forgery.


function mint_approved(
    vData memory info,
    uint256 number_of_items_requested,
    uint16 _batchNumber
) external {
    require(batchNumber == _batchNumber, "!batch");
    address from = msg.sender;
    require(verify(info), "Unauthorised access secret");
    _discountedClaimedPerWallet[msg.sender] += 1;
    require(_discountedClaimedPerWallet[msg.sender] <= 1,"Number exceeds max discounted per address");
    presold[from] = 1;
    _mintCards(number_of_items_requested, from);
    emit batchWhitelistMint(_batchNumber, msg.sender);
}
  • Signature reuse refers to the situation where the same signature can only be used once. In general, the project party will set up a mapping structure in the contract to store whether the signature has been used or not. The code is shown below. In this code, "bytes" represents the hash signature data, and the boolean value represents whether the signature has been used. However, the mint_approved function does not update the storage of this value, thus causing the issue of signature reuse.

Suggestion: To address the issue of signature reuse, it is recommended to add this mapping structure in the code.

mapping(bytes => bool) private usedClaimSignatures
  • Signature forgery: The signature can be forged due to the lack of msg.sender verification when passing the vData type info as a parameter. The specific whitelist verification function "verify" is shown below. The whitelist is stored on a centralized server. When users click on "mint" on the frontend NFT website, the server first verifies if the user is on the whitelist. If they are, the server uses the project party's private key to sign the data and returns the signature. Finally, the user sends the transaction with the signature to the chain for verification. Therefore, the ecrecover() verification here only verifies the project party's address, which can only prove that the signature data was issued by the project party. However, since there is no verification of the caller, i.e., info.from, in the signature data, it allows for signature forgery.

function verify(vData memory info) public view returns (bool) {
    require(info.from != address(0), "INVALID_SIGNER");
    bytes memory cat =
        abi.encode(
            info.from,
            info.start,
            info.end,
            info.eth_price,
            info.dust_price,
            info.max_mint,
            info.mint_free
        );
    // console.log("data-->");
    // console.logBytes(cat);
    bytes32 hash = keccak256(cat);
    // console.log("hash ->");
    // console.logBytes32(hash);
    require(info.signature.length == 65, "Invalid signature length");
    bytes32 sigR;
    bytes32 sigS;
    uint8 sigV;
    bytes memory signature = info.signature;
    // ecrecover takes the signature parameters, and the only way to get them
    // currently is to use assembly.
    assembly {
        sigR := mload(add(signature, 0x20))
        sigS := mload(add(signature, 0x40))
        sigV := byte(0, mload(add(signature, 0x60)))
    }
    bytes32 data =
        keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
        );
    address recovered = ecrecover(data, sigV, sigR, sigS);
    return signer == recovered;
}

Suggestion: To address the issue of signature forgery, it is recommended to add verification of the signature data caller.

require(recovered == info.from, "Invalid signer");

Last updated