Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Reentrancy Prevention with Checks-Effects-Interactions

Hello! Welcome to Module 6 of our course.

In the last module, we focused on making your smart contracts robust and efficient by mastering automated testing. You learned how to verify functionality, check access controls, and, in our last lesson, analyze and optimize for gas consumption. Now that you know how to build contracts that work correctly and efficiently, we're shifting our focus to making them secure. This is arguably the most critical aspect of smart contract development, as vulnerabilities can lead to catastrophic financial losses.

Today's lesson addresses one of the most famous and dangerous vulnerabilities in the history of Ethereum: the reentrancy attack. This type of exploit was responsible for the infamous 2016 DAO hack, which led to the loss of millions of dollars worth of ETH and resulted in a contentious hard fork of the Ethereum blockchain itself. Understanding this attack is not just a history lesson; it's a fundamental requirement for any developer building applications that handle user funds.

By the end of this 60-minute lesson, you will be able to identify the conditions that lead to a reentrancy vulnerability and, more importantly, write secure code to prevent it using the industry-standard Checks-Effects-Interactions pattern.


1. What is a Reentrancy Attack?

At its core, a reentrancy attack occurs when an external contract call is able to "re-enter" the calling function before the original function has finished its execution. This allows an attacker to repeatedly execute parts of the function, often to drain funds.

Imagine a simple bank teller process:

  1. You ask to withdraw $100.
  2. The teller checks your balance; it's $100, so the request is valid.
  3. The teller hands you $100 in cash.
  4. The teller updates your account balance in the ledger to $0.

What if you could interrupt the teller right after step 3 and, before they complete step 4, ask to withdraw $100 again? Since the ledger still says you have $100, the teller would give you another $100. You could repeat this until the vault is empty. This is precisely the logic a reentrancy attack exploits in a smart contract.

To get a clear, animated view of this attack, let's watch a video that breaks it down.

Smart Contracts Hacking: ReEntrancy Attack in Solidity Explained with EASY Examples

This video from JohnnyTime provides an excellent, easy-to-follow explanation of a reentrancy attack using a simple 'Ether Bank' analogy. It clearly shows where the logic goes wrong.

Watch the segment from 01:30 to 05:36. Pay close attention to the diagram explaining how the attacker's 'fallback function' allows it to re-enter the bank's withdraw function before the bank can update the attacker's balance.

The key components of this attack are:

  • A victim contract with a function that sends Ether (or another token).
  • A flawed order of operations in the victim's function: it sends the funds before it updates its internal state (like the user's balance).
  • An attacker contract that calls the victim's function.
  • A special function in the attacker contract (receive() or fallback()) that gets triggered when it receives Ether. This function is programmed to call back into the victim's vulnerable function, creating a malicious loop.

Here is a diagram that visualizes this malicious loop:

Reentrancy Attack Diagram
Caption: This diagram shows the reentrancy attack flow. The attacker's `attack()` function makes an initial call to `withdrawAll()`. The victim contract sends Ether, which triggers the attacker's `receive()` function. This function then calls `withdrawAll()` again, creating a recursive loop that drains the contract before its state is updated.

2. The Vulnerability in Code

Now that you understand the concept, let's see what this vulnerability looks like in Solidity. The flaw is often subtle but devastating.

Consider this simplified EtherBank contract:

// THIS IS A VULNERABLE CONTRACT. DO NOT USE.
contract EtherBank {
    mapping(address => uint) public balances;

    // Users can deposit ETH
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // Users can withdraw their ETH
    function withdraw() public {
        uint amount = balances[msg.sender];
        require(amount > 0, "Insufficient balance");

        // FLAW IS HERE: Interaction before Effect
        // 1. Send the Ether to the user (Interaction)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // 2. Update the user's balance (Effect)
        balances[msg.sender] = 0;
    }
}

The problem lies in the withdraw function. It sends Ether with msg.sender.call{value: amount}("") before it sets balances[msg.sender] = 0. If msg.sender is a malicious smart contract, its receive() function will be executed upon receiving the Ether. That receive() function can immediately call withdraw() again. Since balances[msg.sender] has not yet been set to 0, the require(amount > 0) check passes, and the contract sends the funds again.

The video you watched earlier also presents the code for both the victim and the attacker. It's helpful to see them side-by-side.

Smart Contracts Hacking: ReEntrancy Attack in Solidity Explained with EASY Examples

Let's return to the JohnnyTime video to see the Solidity code for the vulnerable EtherBank and the malicious AttackBank.

First, watch the walkthrough of the vulnerable EtherBank from 05:36 to 06:37. Then, review the explanation of the AttackBank contract from 10:31 to 13:25. Notice how the attacker's receive function contains the logic to re-enter the bank's withdraw function.


3. The Solution: Checks-Effects-Interactions Pattern

Preventing reentrancy attacks relies on following a strict coding pattern known as Checks-Effects-Interactions (CEI). This pattern dictates the order of operations within any function that interacts with external contracts.

  1. Checks: Perform all necessary validation first. This includes things like checking user balances, verifying permissions, or validating inputs using require().
  2. Effects: After all checks pass, apply all effects to the state of your contract. This means updating all state variables (like balances, ownership, etc.). This is the critical step.
  3. Interactions: Only after all internal state has been updated should you interact with external contracts or send Ether.

Let's fix our vulnerable EtherBank contract using this pattern:

// This is the SECURE version of the contract.
contract SecureEtherBank {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint amount = balances[msg.sender];
        
        // 1. Checks
        require(amount > 0, "Insufficient balance");

        // 2. Effects
        balances[msg.sender] = 0; // Update state BEFORE sending Ether

        // 3. Interactions
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

By simply moving balances[msg.sender] = 0; before the external call, the attack is completely neutralized. Now, when the attacker's receive() function re-enters withdraw(), the balance is already 0, so the require(amount > 0) check will fail, and the transaction will revert.

Preventing re-entrancy attacks in Solidity

This article from Infuy provides a concise explanation of the Checks-Effects-Interactions pattern and its importance.

Please read the section titled 'Checks, Effects, Interactions'. It clearly lays out the pattern and shows the corrected code, explaining why it prevents the attack. Note its discussion on the pros and cons—it's a good habit of mind for a developer.

Test your understanding!

You are reviewing a smart contract for a marketplace that allows users to claim refunds. Here is a snippet of the claimRefund function.

// Vulnerable refund function
function claimRefund() public {
    uint refundAmount = refunds[msg.sender];
    require(refundAmount > 0, "No refund available");

    // Pay out the refund
    (bool sent, ) = msg.sender.call{value: refundAmount}("");
    require(sent, "Failed to send refund");

    // Remove the user from the refund mapping
    delete refunds[msg.sender];
}
  1. Identify the reentrancy vulnerability in this function.
  2. Rewrite the function to be secure using the Checks-Effects-Interactions pattern.
Show answer
  1. Vulnerability: The function sends Ether to msg.sender before it deletes the user's refund amount from the refunds mapping (delete refunds[msg.sender]). An attacker contract could re-enter claimRefund() from its receive() function and claim the same refund multiple times before their eligibility is removed.

  2. Secure Version:

    // Secure refund function
    function claimRefund() public {
        uint refundAmount = refunds[msg.sender];
    
        // 1. Checks
        require(refundAmount > 0, "No refund available");
    
        // 2. Effects
        delete refunds[msg.sender]; // Or refunds[msg.sender] = 0;
    
        // 3. Interactions
        (bool sent, ) = msg.sender.call{value: refundAmount}("");
        require(sent, "Failed to send refund");
    }
    

4. A Second Layer of Defense: Reentrancy Guards

While the CEI pattern is the primary way to prevent reentrancy, you can add an extra layer of security using a reentrancy guard. This is a mechanism that "locks" a function when it's first entered and only "unlocks" it when it completes, preventing any re-entrant calls from executing.

This is typically implemented as a modifier. While you could write your own, the standard practice is to use the battle-tested implementation from the OpenZeppelin Contracts library.

Smart Contracts Hacking: ReEntrancy Attack in Solidity Explained with EASY Examples

Let's watch the final segment of the JohnnyTime video, where he explains how to use a mutex lock and, more importantly, OpenZeppelin's ReentrancyGuard.

Watch from 16:30 to 18:14. This will show you how to inherit the ReentrancyGuard contract and apply the nonReentrant modifier to your functions.

To use it in your Hardhat project, you would:

  1. Install OpenZeppelin Contracts (if you haven't already):
    npm install @openzeppelin/contracts

  2. Import and inherit the ReentrancyGuard in your contract.

  3. Apply the nonReentrant modifier to any function that makes external calls and changes state.

Here is how our SecureEtherBank would look with this added protection:

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

// A version with CEI and a Reentrancy Guard
contract SuperSecureEtherBank is ReentrancyGuard {
    mapping(address => uint) public balances;

    // ... deposit function ...

    // Apply the nonReentrant modifier
    function withdraw() public nonReentrant {
        uint amount = balances[msg.sender];
        require(amount > 0, "Insufficient balance");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Using both the CEI pattern and a nonReentrant guard is a best practice. The CEI pattern is your primary defense, while the guard acts as a safety net in case of a logical error.


Conclusion

Congratulations on tackling your first major smart contract security topic! You now understand one of the most historically significant and financially devastating vulnerabilities in blockchain development. More importantly, you have the tools to prevent it. Adhering to security best practices like the ones learned today is what separates hobbyists from professional smart contract developers.

Key Takeaways:

  • A reentrancy attack occurs when an attacker contract recursively calls a function before it has finished executing, typically to drain funds.
  • The vulnerability is caused by performing an external interaction (like sending Ether) before updating the contract's internal state (an effect).
  • The primary defense is the Checks-Effects-Interactions (CEI) pattern, which dictates that you should always update state variables before making external calls.
  • For an additional layer of security, you can use OpenZeppelin's ReentrancyGuard, which provides a nonReentrant modifier to lock a function during its execution.

Preview of the Next Lesson:
With reentrancy under your belt, we'll continue our tour of critical security issues. In the next lesson, we will address another common problem: integer overflow and underflow. You'll learn how these mathematical quirks can lead to unexpected behavior and how to use built-in Solidity features and libraries to prevent them.

Can't find a good explanation? Sign up and we'll make it for you

Sign up