Do you want to understand the world of hackers? Do you want to experience what it feels like to be a hacker? How to use a reentrancy attack contract to attack an NFT project? Spinach will take you to experience the hacker's perspective on how to find vulnerabilities in smart contracts and carry out attacks.
Since this article involves smart contract code technology, Spinach will explain the logic of each line of code clearly for those who do not understand the technology, making it easier for everyone to understand smart contracts and find logical vulnerabilities within them.
Remember: This case study is for educational purposes only. Attacking smart contracts is an unethical behavior that may cause users to unintentionally lose their crypto assets and may pose a threat to the security of the entire blockchain network.
This case study is based on the final exam questions from Stanford University's 2021 cryptography course. Question link: https://cs251.stanford.edu/hw/final2021.pdf
If you do not understand what a smart contract and a function are, let Spinach explain: A smart contract is an automated, programmable program that runs on the blockchain. We input certain parameters into the smart contract, and it automatically executes specific operations based on the logic written by the author. Various NFT projects and tokens we are familiar with are individual smart contracts.
A function is a specific feature within a smart contract. When we call a function in a smart contract and pass in parameters, it triggers the code logic of that function. For example, when Spinach transfers 100 USDT to a user on-chain, it actually calls the transfer function of the USDT smart contract. In the image, I passed the address and amount parameters to the function, and the smart contract executes the operation based on the parameters I provided.
So what is a reentrancy attack?#
A reentrancy attack in blockchain refers to an attacker repeatedly executing certain functions in a smart contract, leading to abnormal behavior of the contract. Reentrancy attacks are one of the most common attacks in smart contracts, with several incidents occurring each year resulting in losses of millions of dollars for various projects.
The most famous reentrancy attack incident occurred in 2016 when The DAO contract on Ethereum was attacked, and hackers stole 3,600,000 ETH from the contract. This incident also led Ethereum to roll back the ledger to a state before the attack, resulting in the creation of Ethereum's hard fork—Ethereum Classic (ETC). Interestingly, this reentrancy attack event did not occur in the current Ethereum ledger.
Now let's step into the hacker's perspective and understand how hackers find vulnerabilities to carry out reentrancy attacks.
Let's look at the question: The following Solidity code snippet is for an airdrop containing 16,384 non-fungible tokens (NFTs). Users can claim up to 20 NFTs at once by calling the mintNFT() function on the NFT contract.
(The image below shows the source code of this function; those who understand the technology can try to find vulnerabilities first.)
There are three questions:
A) Assume that 16,370 NFTs have already been minted, so totalSupply() == 16,370. Please explain how a malicious contract could lead to more than 16,384 NFTs being minted. What is the maximum number of NFTs that the attacker can generate?
Hint: What happens if the onERC721Received() of the calling address is malicious? Carefully check the minting loop and consider the reentrancy vulnerability.
In question A, we need to find the logical vulnerability in the contract, while in question B, we need to implement the specific attack contract code.
B) Write a malicious Solidity contract code to implement the attack described in part (a), assuming the current value of totalSupply() is 16,370.
In question C, we will fix the reentrancy attack vulnerability.
C) Which line of Solidity code would you add or change in the code from the previous page to prevent your attack? Note that a single transaction should not mint more than 20 NFTs.
Spinach will summarize:
This question states that this is a mint function for an NFT contract with a total supply limit of 16,384 NFTs, where a single address can mint a maximum of 20 NFTs at a time. Assuming that 16,370 NFTs have already been minted, how can we mint NFTs exceeding the specified total limit? How many can we exceed? How to implement the attack code? How to fix the vulnerability?
Next, let's start solving the problem. Those who understand the technology can try it themselves first. To accommodate those who do not understand the technology, Spinach will explain each line of code in Chinese and will also clarify the logic, so there is no need to worry about not understanding.
First, we can see that to call the MintNFT function, the input parameter must be an unsigned integer (understood as a number), and there are several preconditions (require):
- The total supply must be < 16,384 when called.
- The number to be minted must be > 0.
- No more than 20 can be minted at once.
- The already minted quantity + this minting quantity cannot exceed 16,384.
- The price must match the quantity.
As long as the preconditions are met, calling the function will trigger a for loop. Regardless of what the for loop is, once triggered, it will first check the current number of NFTs and then call the SafeMint function repeatedly based on the input parameters (numbers). For example, if you input 14, it will call the SafeMint function 14 times, and you will receive 14 NFTs.
When the SafeMint function is called, the contract will check whether the NFT to be minted has already been minted. If not, it will send the NFT to your wallet address and increment the current NFT issuance by 1. If we mint this contract normally, we can input and call the MintNFT function a maximum of 16,384 - 16,370 = 14 times, because each call increments the current issuance, and this number cannot exceed the limit of 16,384.
So how can we operate to exceed the specified total of 16,384 NFTs?#
We can note that there is another assumption: if the calling address is a contract account, it will first check whether this contract can receive ERC721 standard (NFT) tokens. If it cannot, it will be rejected, and it will also trigger the onERC721Received function of the NFT contract.
So what are IERC721Receiver and onERC721Received?
In simple terms, IERC721Receiver is an interface that a contract must implement to receive ERC721 standard (NFT) tokens. This is why we need to check whether the contract address can receive ERC721 tokens first. If the other party's contract has not implemented this interface, it will not receive the NFTs.
To implement the IERC721Receiver interface, we must add the onERC721Received callback function in the contract.
In the ERC721 standard, when one contract sends an NFT to another contract, it will call the onERC721Received function of the other party, passing in parameters such as the sender's address, the original holder's address of the NFT, the NFT ID, and additional data. This function will also return a value indicating successful receipt.
What is a callback function?#
A callback function is typically used to handle received external data, such as the logic triggered when receiving a transfer from another contract or receiving an NFT from another contract. In simple terms, it is a function that automatically triggers execution logic upon receiving a transfer. In addition to returning a value to inform the other party of successful receipt, custom code logic can also be written.
This callback function is actually the key to attacking this NFT contract. First, we know that if we use our own wallet (like Metamask) to call the MintNFT function, we can mint a maximum of 14 NFTs.
But what if we use a smart contract to call the MintNFT function? Where is the vulnerability in the NFT contract? What is the attack logic?
First, we need to know: as long as the five preconditions (require) are met, we can call the MintNFT function. To find the logical vulnerability in the NFT contract, we mainly need to focus on the preconditions.
To achieve a reentrancy attack, we need to repeatedly call the MintNFT function while satisfying the preconditions, allowing the number of NFTs minted to exceed the specified total.
The attack logic is:
Add the attack code in the callback function onERC721Received of the attack contract. When the attack contract receives an NFT from the NFT contract, it will automatically trigger the callback function's attack logic to call the mintNFT function of the NFT contract again, achieving the effect of a reentrancy attack. Let Spinach explain the specific logic slowly:
First, the attack contract will pass in 14 (16,384 - 16,370 = 14) to call the MintNFT function. If it fills in 15, it will not pass the precondition (4. The already minted quantity + this minting quantity cannot exceed 16,384). So after passing the preconditions, calling the MintNFT function will trigger a for loop that repeatedly calls the SafeMint function 14 times. Each loop call will trigger the callback function of the attack contract.
Each time an NFT is minted and transferred to the attack contract, it will trigger the attack logic of the callback function. The attack logic of this callback function is that each time it receives an NFT, it will automatically call the mintNFT function of the NFT contract again. This may sound a bit convoluted, so let me clarify:
- The attack contract passes in 14 as a parameter to call the mintNFT function of the NFT contract.
- Since the parameter 14 meets the precondition requirements, it can be successfully called. The mintNFT function will then call the SafeMint function 14 times to mint NFTs. At this step, the attack contract will ultimately receive 14 NFTs.
- Since the attack contract is a smart contract, the NFT contract will call the onERC721Received function of the attack contract during the minting process to check whether the target contract implements the IERC721Receiver interface.
- Since the NFT contract will execute the SafeMint function 14 times, it will call the callback function of the attack contract 14 times, triggering the attack logic of the callback function 14 times.
- The specific attack logic is: after receiving the first NFT, the callback function triggers the logic to call the mintNFT contract again, inputting the parameter 13, because it has already received 1 NFT, and there are still 13 NFTs left to reach the total limit, so the parameter 13 can pass the precondition.
- After receiving the second NFT, the callback function continues to trigger the attack logic to call the mintNFT function, inputting the parameter 12, where 12 + 2 = 14, and so on, as long as it does not exceed the number 14, it can pass the precondition check.
- Therefore, the answer to question A is: due to the logical vulnerability in the preconditions, using a callback function with malicious attack logic can mint more than the total of 16,384 NFTs.
- In addition to the originally minted 14 NFTs, the maximum number of NFTs that the attacker can mint is: 13 + 12 + 11 + 10 + 9 + 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 = 91 NFTs.
Now, those who do not understand the technology may be confused. Haven't 14 NFTs already been minted? Why can they continue to be minted? Where is the vulnerability in the preconditions?#
Let Spinach continue to explain.
First, everyone needs to understand Ethereum's block generation mechanism. In simple terms, when we call a smart contract, all parameters and contract states we pass in will be packaged into a transaction waiting for the miner to process and generate a block. Until the block is generated, these transactions essentially have not become "facts." This means that before the block is generated, the first call of 14 NFTs has not actually been minted.
Not having been truly minted means that when calling the contract, the existing issuance will remain at 16,370 until the block containing this transaction is generated. Now let's look at the vulnerabilities in the preconditions. We only need to focus on these two:
- The total supply must be < 16,384 when called.
- The already minted quantity + this minting quantity cannot exceed 16,384.
Combining the block generation mechanism and the preconditions, we understand where the problem lies. Since the existing issuance will remain at 16,370 before the block is generated, we look at the first precondition. Since the issuance number does not change before the block is generated, it can pass the first precondition regardless of how many times it is called. Now let's look at the fourth condition: as long as the already minted quantity + the minting quantity does not exceed 16,384, it can pass.
This means that as long as the minting quantity does not exceed 14, it can pass the fourth precondition. Thus, the logic becomes clear:
- Because the number does not change before the block is generated (contract state).
- As long as each minting does not exceed 14 NFTs, it can perfectly bypass the first and fourth requirements for a reentrancy attack.
- Each time the attack contract receives an NFT, it will repeatedly call mintNFT to mint NFTs totaling no more than 14.
After the block is generated, the final state of the NFT contract is that the mintNFT function has actually been called 14 times, resulting in a total minting of 14 + 13 + 12 + 11 + 10 + 9 + 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 = 105 NFTs. In question B, we need to implement the attack contract for this attack, so let's start designing the attack contract.
- First, we need to implement the IERC721Receiver interface in the attack contract so that we can receive ERC721 standard NFTs.
- We will define the address of the NFT contract as hashmasks (just a name).
- Then we create an attack function to start writing the attack logic.
- We set num as the number of NFTs held by the attack contract.
- As long as num is < 14, it will call the mintNFT function of the NFT contract, passing in the parameter 14 - num, which decreases continuously.
- Then we start writing the onERC721Received callback function. First, we set the received parameter format according to the official format, and then add our custom attack logic: once an NFT is received, it will automatically trigger the attack function to call the mintNFT function of the NFT contract.
Now that we know the specific attack logic, let's look at question C.
C) Which line of Solidity code would you add or change in the code from the previous page to prevent your attack?
You can place the line _totalSupply++;
inside the _safeMint
method after validating the NFT call:
This way, when the contract is under a reentrancy attack, since _totalSupply
has not yet increased, the value of mintIndex
during the second entry into the mintNFT
function will be the value from the first mint, triggering the error 'ERC721: token already minted'
, effectively ensuring the contract's security and causing the reentrancy attack to fail.
The answers to questions B and C are sourced from: https://github.com/qiwihui/blog/issues/157
Finally, I would like to pay tribute to all the workers and white hats in the security field of the crypto industry. The crypto world is a dark forest and also a hacker's paradise, where many security incidents occur every day.
In this challenging and opportunity-filled industry, I thank all the explorers who contribute to industry security, and I hope Spinach's article can help those in front of the screen improve their understanding.
If you like my article, you can follow my WeChat public account.
If there are any errors, please feel free to point them out.
Article link: