Create your own
Lesson illustration

Preventing Reentrancy with Checks-Effects-Interactions

Hello! Let's dive into our next lesson in the "Advanced Solidity & Secure Development" module.

Introduction

In our previous lesson, we focused on improving how our contracts communicate failures by implementing custom errors. This practice makes your contracts more gas-efficient and provides a much better experience for developers and users when things go wrong.

Today, we shift from handling failures gracefully to preventing one of the most infamous and historically significant vulnerabilities in smart contract development: reentrancy. The notorious 2016 DAO hack, which led to the hard fork creating Ethereum Classic, was the result of a reentrancy attack. Understanding and preventing it is non-negotiable for any serious Solidity developer.

Learning Outcome: By the end of this lesson, you will be able to apply the Checks-Effects-Interactions pattern to prevent reentrancy attacks.

We will explore:

  • The mechanics of a classic reentrancy attack.
  • The Checks-Effects-Interactions (CEI) pattern as the primary defense.
  • More complex reentrancy scenarios to understand why CEI is a superior design principle.
  • The role of reentrancy guards as a secondary defense mechanism.

1. Understanding the Reentrancy Attack

At its core, a reentrancy attack occurs when a contract makes an external call to another, untrusted contract. If the untrusted contract is malicious, it can call back into the original contract before the first call has finished its execution. If the original contract's state hasn't been updated yet, the attacker can exploit this inconsistency.

The classic example is a simple bank or vault contract with a flawed withdraw function.

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

To see how this works in practice, let's watch a clear explanation of the attack. This video uses a simple bank analogy and then walks through the vulnerable code.

Watch from 01:30 to 06:37. The first part (until 05:24) explains the concept with a great visual analogy. The second part shows the vulnerable Solidity code for an 'EtherBank' contract. Focus on the order of operations in the withdraw function.

As the video demonstrates, the vulnerability lies in this specific sequence of operations in the withdraw function:

  1. Check: It verifies the user has a balance.
  2. Interact: It sends the Ether to the user (msg.sender.call{...}).
  3. Effect: It updates the user's balance to zero.

When the msg.sender is a malicious contract, the Ether transfer triggers its receive() or fallback() function. Inside that function, the attacker simply calls withdraw() again. Because the original contract's state (the user's balance) has not yet been set to zero, the check passes again, and the attacker withdraws the funds a second time. This loop continues until the contract is drained.

2. The Solution: The Checks-Effects-Interactions Pattern

The most robust way to prevent reentrancy is to structure your functions according to the Checks-Effects-Interactions (CEI) pattern. This is a simple but powerful design principle that dictates the order of operations within a function.

Security Considerations - Checks-Effects-Interactions

The official Solidity documentation provides the canonical definition of this pattern. It's a fundamental concept for secure development.

Read the section titled 'The Checks-Effects-Interactions pattern'. It clearly defines each part of the pattern and shows the corrected code for the vulnerable Fund contract.

Let's break down the pattern and apply it to the vulnerable EtherBank contract from the video:

  1. Checks: First, perform all validations on inputs and state. In the previous lesson, we learned to use custom errors for this.

    if (balances[msg.sender] == 0) {
        revert NoBalanceToWithdraw();
    }
    uint256 amount = balances[msg.sender];
    
  2. Effects: Second, apply all changes to the contract's state variables. This is the critical step. You update the state as if the interaction has already succeeded.

    balances[msg.sender] = 0;
    
  3. Interactions: Finally, interact with external contracts (e.g., send Ether, call another contract's functions).

    (bool success, ) = msg.sender.call{value: amount}("");
    if (!success) {
        // If the transfer fails, revert the state change.
        balances[msg.sender] = amount;
        revert TransferFailed();
    }
    

By zeroing the user's balance before sending the Ether, the reentrancy attack is neutralized. If the attacker's contract calls back into withdraw, the "Check" at the beginning will find a balance of zero and the call will revert.

3. Why CEI is a Superior Design Pattern

Simply fixing the classic reentrancy case is not enough. Modern DeFi protocols are complex, and vulnerabilities can be much more subtle. The CEI pattern is powerful because it protects against a wide range of reentrancy variants, not just the simple one.

This next resource is a deep dive that explores more advanced forms of reentrancy. It will challenge you to think about state consistency across an entire system.

The Ultimate Guide To Reentrancy

Owen Thurm's 'The Ultimate Guide To Reentrancy' is an excellent resource for understanding the full scope of this vulnerability. We'll focus on a few key examples that highlight why CEI is so important.

Please watch the following three segments: Classical Reentrancy & CEI (03:07 - 07:20): This is a great recap of what we just discussed, reinforcing that CEI is the best solution. Cross-Function Reentrancy (07:20 - 10:37): Pay close attention to this. It shows how an attacker can re-enter a different function in the contract to exploit an outdated state, even if the initial function is protected by a reentrancy guard. Read-Only Reentrancy (17:29 - 23:46): This is a subtle but critical concept. It explains how external calls can expose outdated state to other contracts or front-ends that are just reading data, leading to exploits.

Let's summarize the key insights from the video:

  • Cross-Function Reentrancy: This demonstrates the limitation of using simple locks or guards on a single function. An attacker can be given control during a withdraw call and then use that control to call a transfer function, for example. If the balance hasn't been updated (an "Effect" that was postponed), the attacker can transfer funds they shouldn't have. This proves that state must be consistent before any external interaction.
  • Read-Only Reentrancy: This is particularly relevant given your front-end development background. Imagine a dApp that reads a getCollateralRatio() view function to decide if a user can take a loan. If the underlying protocol performs an external call before updating its internal state, your dApp could read a stale, incorrect collateral ratio. This could lead your front-end to display misleading information or allow an action that should be blocked. CEI ensures that by the time any external entity (a contract or a front-end query) can act, the state is already consistent.

4. An Alternative: The nonReentrant Modifier

While CEI is the fundamental pattern to follow, another common tool is a reentrancy guard. This is typically implemented as a modifier that acts as a mutex (a lock), preventing a function from being re-entered while it's already executing.

Preventing re-entrancy attacks in Solidity

This blog post from Infuy provides a clear explanation of how reentrancy guards work and contrasts them with the CEI pattern.

Read the sections 'The nonReentrant function modifier' and 'Checks, Effects, Interactions'. The first part shows how to build a custom guard and how to use OpenZeppelin's standard ReentrancyGuard. The second part provides a concise comparison of the two approaches.

The most common implementation is OpenZeppelin's ReentrancyGuard, which provides a nonReentrant modifier.

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

contract Bank is ReentrancyGuard {
    // ...
    function withdraw() public nonReentrant {
        // ... function logic
    }
}

The modifier sets a lock variable (_entered = true) upon entry and releases it (_entered = false) upon exit. Any re-entrant call will find the lock engaged and will revert.

CEI vs. Reentrancy Guard

  • Reentrancy Guard:
    • Pro: Easy to apply and clearly signals intent. Good defense-in-depth.
    • Con: Can be a blunt instrument. As we saw with cross-function reentrancy, it might not protect the entire contract's state logic. It also adds a small gas overhead for the SSTORE and SLOAD operations of the lock variable.
  • Checks-Effects-Interactions (CEI):
    • Pro: A more fundamental and robust design pattern that prevents a wider class of reentrancy attacks. It has no gas overhead on the happy path.
    • Con: Requires developer discipline to apply correctly and consistently.

The best practice is to always use the CEI pattern. The nonReentrant modifier should be seen as an additional layer of security, not a substitute for proper function structuring.

Conclusion

You have now learned to identify and prevent one of Solidity's most dangerous vulnerabilities. By internalizing the Checks-Effects-Interactions pattern, you are adopting a mindset that prioritizes state consistency, which is the key to writing secure smart contracts.

Key Takeaways:

  • Reentrancy exploits a delay between an external call (Interaction) and a state update (Effect).
  • The Checks-Effects-Interactions (CEI) pattern is the primary and most effective defense. It mandates a strict order of operations: validate inputs, update state, then interact with other contracts.
  • CEI is a robust design principle that protects against complex variants like cross-function and read-only reentrancy.
  • The nonReentrant modifier (a mutex lock) is a useful defense-in-depth mechanism but does not replace the need for the CEI pattern.

Next Steps:

Understanding how to prevent reentrancy is the first step. The next is learning how to spot it in the wild. In our next lesson, we will audit contracts for reentrancy vulnerabilities using both static analysis tools like Slither and manual code review, putting your new knowledge into practice.

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

Sign up