RoadToChain Logo
RoadToChain
T1/M1.5/Reentrancy Explained
beginner 15m read

Reentrancy Explained

Understanding checks-effects-interactions and recursive call vulnerabilities.

#security #reentrancy #hacks

Let's address the single most expensive security vulnerability in smart contract history.

Imagine you have ₹100 stored in a smart contract vault.

You press Withdraw.

Your wallet opens, and you receive ₹100.

But then, before your transaction screen even has a chance to update your balance, your wallet rings again:

  • Received: ₹100
  • Received: ₹100
  • Received: ₹100
  • Received: ₹100
  • Received: ₹100

You stare at the screen, your heart racing. The transaction isn't stopping. It keeps pouring funds into your wallet, over and over, looping relentlessly like a glitch in the matrix.

"Wait... WTF is actually happening?! 😭"

You check the contract balance: it is dropping to zero. The contract is bleeding all its deposits, and you didn't even write a loop. This is the dread Reentrancy Attack in action.


1. The Story: The DAO Hack and the split of Ethereum

In 2016, a decentralized venture fund called The DAO was launched on Ethereum. Within weeks, it had gathered over $150 million worth of ETH (representing 15% of all circulating ETH at the time).

Shortly after launch, an attacker noticed a simple 4-line vulnerability in the DAO's withdrawal code—representing this exact emotional loop. By executing a recursive call, the hacker drained over $60 million worth of ETH in a few hours.

The hack was so massive that it threatened the survival of the Ethereum network. To recover the stolen funds, the community had to perform a highly controversial "hard fork," splitting the blockchain into two networks: Ethereum Classic (the old chain where the hack remained) and Ethereum (the new chain where the transaction history was rewritten to return the funds).

A simple coding mistake forced a global blockchain to split its entire history.


2. The Metaphor: The ATM Cash Dispenser Flaw

To understand how reentrancy executes, let's look at a physical bank analogy (restricted to a tight 20% of our scope):

Imagine a physical bank ATM with a mechanical timing flaw:

  • The Safe Payout (Checks-Effects-Interactions):
    1. You swipe your card and request $100 (Check).
    2. The ATM check-in computer verifies you have $100 and subtracts $100 from your screen account balance (Effect).
    3. The ATM physical dispenser door clicks open and hands you the cash (Interaction).
  • The Vulnerable Payout: Imagine the ATM executes in a broken sequence:
    1. You request $100.
    2. The ATM verifies you have it, skips updating your balance, and immediately opens the door to hand you the cash.
    3. The Interception: While the dispenser door is still holding the cash open, you reach out, intercept the cash dispenser sensor, and hit the "Withdraw $100" button again before the screen can update your balance.
    4. The machine sees your account still shows the original $100 balance, so it dispenses cash again.
    5. You repeat this loop 500 times until the cash vault is completely empty, and only then do you let the ATM complete its task and update your screen balance.

This is exactly what happens during a reentrancy attack. The attacker intercepts the payout interaction before the contract can subtract their balance.


3. The Visual Reentrancy Flow Diagram

Our frontend represents this paused execution path dynamically. Notice how the vault pauses its execution line on msg.sender.call, allowing the malicious contract fallback to re-enter withdraw() recursively:

SmartAccount.sol
  [ Attacker Contract ] ──1. Call withdraw() ──> [ VulnerableVault ]
           │                                             │
      3. Re-enter                                    2. Send ETH
      withdraw() ────────────────────────────────────────┤
           │                                             ▼
           ▼                                      (Control handed over)
  [ Loop continues until vault is empty! ]

[!TIP] VISUAL TRIGGER FOR FRONTEND: This diagram illustrates the recursive callback path. In the frontend dApp UI, animate this loop as a circular, pulsating signal path. As the attacker re-enters the vault, show the vault's internal execution state "freezing" at the transfer step, and highlight the multiple concurrent withdrawal balances piling up inside the attacker's wallet.

Reentrancy Attack
A reentrancy attack occurs when a vulnerable vault sends ETH before resetting the sender's balance, allowing a malicious fallback function to recursively drain funds.

4. Technical Explanation: The call Handoff & Checks-Effects-Interactions

Let's dive into the technical details (forming 80% of our focus) to see how the EVM handles this process under the hood.

1. The Call Execution Pause

In Solidity, when you transfer raw ETH to a contract address, you execute the following opcode line:

SmartAccount.sol
solidity
(bool success, ) = msg.sender.call{value: amount}("");

This is not a simple database ledger transfer. In the EVM:

  1. The vault contract pauses its own thread execution.
  2. The EVM jumps directly to the recipient address.
  3. If the recipient is a smart contract, the EVM executes its receive() or fallback() function.
  4. Control is handed over completely. The recipient contract is now running the active CPU thread, while the vault sits paused, waiting for the call to return.

If the recipient is an attacker contract, their fallback function immediately calls withdraw() again:

SmartAccount.sol
solidity
// Attacker's interceptor fallback
receive() external payable {
    if (address(vault).balance >= 1 ether) {
        vault.withdraw(); // RE-ENTRANCY! Re-enters the vault while it's still paused!
    }
}

2. The Checks-Effects-Interactions Solution

Let's look at the vulnerable code compared to the secure, audit-ready fix:

SmartAccount.sol
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
 
contract VulnerableVault {
    mapping(address => uint256) public balances;
 
    // ❌ VULNERABLE: Performs interaction BEFORE updating state effect!
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
 
        // 1. INTERACTION: Sends raw ETH to msg.sender (HANDS OVER CONTROL!)
        (bool success, ) = msg.sender.call{value: amount}(""); 
        require(success, "Transfer failed");
 
        // 2. EFFECT: Updates state balance too late! (Never reached during attack!)
        balances[msg.sender] = 0; 
    }
}
 
contract SecureVault {
    mapping(address => uint256) public balances;
 
    // ✅ SECURE: Follows the Checks-Effects-Interactions pattern strictly!
    function withdraw() public {
        // 1. CHECKS (Check conditions first)
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
 
        // 2. EFFECTS (Update balance FIRST before sending!)
        balances[msg.sender] = 0; 
 
        // 3. INTERACTIONS (Send ETH last!)
        (bool success, ) = msg.sender.call{value: amount}(""); 
        require(success, "Transfer failed");
    }
}
  • The Vulnerable Flow: In VulnerableVault, because balances[msg.sender] = 0 happens after the call, the state remains unchanged when the attacker re-enters. The require check passes again and again.
  • The Secure Flow: In SecureVault, we execute the Effect first, clearing the balance to 0 before handing over control. When the attacker's fallback function re-enters withdraw(), the Check loads balances[msg.sender], sees 0, and reverts the transaction immediately.

// Reality Check

Reentrancy is not an ancient history lesson. It is still the single most exploited smart contract bug today, responsible for hundreds of millions of dollars in DeFi hacks annually. Any contract that transfers raw ETH or calls an untrusted receiver address is wide open to reentrancy unless protected strictly!

— Production Engineering Principle

// I Got This Wrong

The Reentrancy Guard Protection: While following Checks-Effects-Interactions is your first line of defense, advanced developers also import OpenZeppelin's ReentrancyGuard and apply the nonReentrant modifier. This modifier uses a simple boolean state lock (mutex) to prevent any function from executing recursively:

SmartAccount.sol
solidity
// Basic mutex guard logic
modifier nonReentrant() {
    require(!locked, "Reentrancy locked");
    locked = true;
    _;
    locked = false;
}
— Postmortem Confession

System Design Challenge
Think Active

Analyze the SecureVault code. If the call transfer fails for some reason (e.g. the receiver contract reverts), why is it critical that Solidity rolls back the state changes using require(success)? What would happen if we didn't check the return value?

[ Think Before Continuing ]

Was this lesson helpful?

Let us know what you think of this specification. (submitting anonymously)