DEFI LIBRARY FOUNDATIONAL CONCEPTS

Demystifying Reentrancy A Step-by-Step Tutorial for DeFi Beginners

8 min read
#DeFi #Smart Contracts #Blockchain #security #Reentrancy
Demystifying Reentrancy A Step-by-Step Tutorial for DeFi Beginners

Introduction

Reentrancy is one of the most talked‑about vulnerabilities in the world of smart contracts. It is responsible for some of the biggest losses in decentralized finance, yet it can be understood with a few simple concepts. This tutorial walks you through what reentrancy is, why it matters, how it works in practice, and most importantly, how you can guard your contracts against it. By the end you will have a solid mental model and a checklist you can use whenever you write or audit a DeFi smart contract.

What Is Reentrancy?

At its core, reentrancy is an interaction pattern. When a smart contract calls another contract, control jumps to the callee, the callee executes, and then control returns to the caller. Reentrancy happens when, during that callback, the callee makes another call back to the original caller before the first call has finished. In other words, the caller gets “re‑entered” before it has finished its own work.

Reentrancy is not a flaw in the language or the blockchain itself; it is a feature of the execution model that, if misused, can be exploited. The classic example is a contract that sends funds to a user and only then updates the user’s balance. A malicious user can call the contract, receive a payment, then invoke the same function again before the balance update happens, and drain the contract.

Why Reentrancy Matters

  • Financial Loss: Reentrancy bugs have led to millions of dollars in stolen funds.
  • Loss of Trust: Users expect that once they interact with a DeFi protocol, their funds are safe.
  • Regulatory Impact: Reentrancy attacks expose vulnerabilities that regulators scrutinize.
  • Technical Reputation: A smart contract with a reentrancy flaw reflects poorly on the developer team.

Because DeFi contracts are often public and immutable, a reentrancy exploit can be executed repeatedly, making prevention critical.

Classic Reentrancy Attack: The DAO

The DAO attack in 2016 is the textbook case. The DAO had a withdraw() function that transferred Ether to a caller before updating the caller’s balance. An attacker created a malicious contract that, in its fallback function, called withdraw() again, repeating the process until the DAO’s balance was drained.

This attack introduced the term “reentrancy vulnerability” into the DeFi lexicon. It also spurred the Ethereum community to adopt stricter coding patterns and tools.

Step‑by‑Step Reentrancy Demo

Below is a minimal example of a vulnerable contract in Solidity. It illustrates how reentrancy can be exploited.

pragma solidity ^0.8.0;

contract VulnerableBank {
    mapping(address => uint256) public balances;

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

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // The vulnerable line: transfer before state change
        (bool sent, ) = payable(msg.sender).call{value: amount}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] -= amount;
    }
}

Attack Contract

pragma solidity ^0.8.0;

contract Attacker {
    VulnerableBank public bank;
    address public owner;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
        owner = msg.sender;
    }

    // Fallback function triggered by the bank's call
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH");
        bank.deposit{value: 1 ether}();
        bank.withdraw(1 ether);
    }

    function collect() external {
        payable(owner).transfer(address(this).balance);
    }
}

How the Exploit Works

  1. Deposit: The attacker deposits 1 ETH into the bank.
  2. First Withdrawal: The attacker calls withdraw(1 ETH). The bank sends 1 ETH to the attacker via call.
  3. Reentrancy: The attacker’s receive() function is triggered. It immediately calls bank.withdraw(1 ETH) again before the bank has reduced the attacker’s balance.
  4. Repeat: Steps 2 and 3 repeat until the bank’s balance is exhausted.
  5. Collect: The attacker transfers all stolen Ether to the owner.

The root cause is that the bank’s state change (reducing the balance) occurs after the external call.

Detecting Reentrancy Vulnerabilities

1. Code Review Checklist

  • External calls before state changes?
    Any call, send, transfer, or low‑level call that sends Ether should be followed by all state updates before the call completes.

  • Fallback or receive functions?
    Contracts that have fallback or receive functions can act as malicious callbacks. Evaluate whether they can call back into the vulnerable contract.

  • Modifier usage?
    Look for functions marked with nonReentrant. If absent, consider whether the function involves external calls and state changes.

  • Use of msg.sender after an external call
    If the contract relies on msg.sender after an external call, reentrancy may occur.

2. Automated Tools

  • Slither – Static analysis that flags potential reentrancy patterns.
  • Manticore – Symbolic execution to find attack vectors.
  • MythX – Cloud‑based analysis covering reentrancy checks.
  • Remix Security plugin – Real‑time feedback in the IDE.

Running a combination of these tools before deployment gives a high level of confidence that reentrancy has been addressed.

Preventing Reentrancy

1. Checks-Effects-Interactions Pattern

The canonical guard is to reorder operations:

  1. Checks – Validate all conditions (require statements).
  2. Effects – Update all internal state (balances, counters).
  3. Interactions – Make external calls (call, transfer, etc.).

Rewriting the vulnerable withdraw function:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    balances[msg.sender] -= amount;          // Effect
    (bool sent, ) = payable(msg.sender).call{value: amount}("");
    require(sent, "Failed to send Ether");   // Interaction
}

Now, by the time the external call is made, the caller’s balance is already updated, so any reentrancy attempt will fail the require check.

2. NonReentrant Modifier

The OpenZeppelin ReentrancyGuard contract provides a reusable modifier:

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

contract SafeBank is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        // ... body
    }
}

The modifier uses a mutex that prevents the same call stack from reentering the function.

3. Avoid Low‑Level Calls for Payments

Prefer using transfer or call with a limited gas stipend only when necessary. transfer forwards 2300 gas, which is often insufficient for reentrancy but also limits contract execution to simple fallback functions.

4. Design for Idempotency

Make functions that can be safely called multiple times without side effects. For example, a withdraw that checks that the amount requested is less than or equal to the remaining balance.

5. Use Safe Libraries

  • SafeERC20 for token transfers.
  • Address.sendValue to safely forward Ether with reentrancy protection.

Practical Example: A Reentrancy‑Free Liquidity Pool

Below is a stripped‑down version of a liquidity pool that follows best practices:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract LiquidityPool is ReentrancyGuard {
    mapping(address => uint256) public lpTokens;
    mapping(address => uint256) public poolBalances;
    IERC20 public token;

    constructor(IERC20 _token) {
        token = _token;
    }

    function deposit(uint256 amount) external nonReentrant {
        require(amount > 0, "Zero amount");
        token.transferFrom(msg.sender, address(this), amount);

        // Update balances before sending any tokens back
        lpTokens[msg.sender] += amount;
        poolBalances[address(token)] += amount;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(lpTokens[msg.sender] >= amount, "Insufficient LP");
        lpTokens[msg.sender] -= amount;
        poolBalances[address(token)] -= amount;

        // Interaction after state changes
        token.transfer(msg.sender, amount);
    }
}

Notice how the nonReentrant modifier is combined with the Checks‑Effects‑Interactions pattern. Even if a malicious user implements a fallback that calls withdraw, the require will fail because the LP balance has already been reduced.

Tools and Libraries to Assist You

Tool Purpose Key Feature
Slither Static analysis Detects reentrancy patterns automatically
Manticore Symbolic execution Finds possible attack vectors
MythX Cloud analysis Reports reentrancy alongside other vulnerabilities
OpenZeppelin Contracts Secure libraries Provides ReentrancyGuard, SafeERC20, etc.
Foundry Development framework Offers built‑in tests and fuzzing

Running a full suite of tests, static analysis, and fuzzing before deployment is a non‑negotiable step in DeFi development.

Checklist Before Going Live

  1. Use the Checks‑Effects‑Interactions pattern in all functions that interact with external contracts or send Ether.
  2. Wrap sensitive functions with the nonReentrant modifier or an equivalent guard.
  3. Audit all fallback and receive functions; they can be entry points for reentrancy.
  4. Run Slither and MythX to ensure no flags remain.
  5. Fuzz test the contract using Foundry or Echidna to look for edge cases.
  6. Review gas usage – sometimes reentrancy protection can increase cost; ensure it stays within acceptable limits.
  7. Document the design so future developers understand the reentrancy protections in place.

Conclusion

Reentrancy is a subtle but powerful vulnerability that can devastate DeFi protocols. By grasping the underlying mechanics—how a call stack can be re-entered before state changes—you can write code that is resistant to this attack vector. The key practices are straightforward:

  • Checks‑Effects‑Interactions
  • NonReentrant guards
  • Avoid low‑level calls where possible
  • Leverage trusted libraries

Combine these with thorough static analysis, fuzzing, and code review, and you will significantly reduce the risk of reentrancy attacks. Armed with this knowledge, you are better prepared to build safer, more trustworthy DeFi applications.

Sofia Renz
Written by

Sofia Renz

Sofia is a blockchain strategist and educator passionate about Web3 transparency. She explores risk frameworks, incentive design, and sustainable yield systems within DeFi. Her writing simplifies deep crypto concepts for readers at every level.

Contents