Uncategorized

What Are Re-Entrancy Attacks? Smart Contract Security Guide

What
Email :113

If you’ve built on Ethereum, you’ve heard the warning: never call external contracts before updating your state. Ignore this and your protocol becomes vulnerable to re-entrancy attacks—still happening in 2024, still draining millions.

This guide covers how these attacks work, walks through the mechanics with real Solidity code, examines The DAO hack, and shows you how to prevent them.

Understanding Re-Entrancy Attacks

A re-entrancy attack happens when a malicious contract calls back into the calling contract before the first execution completes. The attacking contract “re-enters” the vulnerable function, running the same code multiple times before state updates happen. This lets attackers drain funds or manipulate state in ways that should be impossible.

The vulnerability comes from a basic Ethereum characteristic: external function calls can trigger arbitrary code in other contracts. When Contract A calls Contract B, Contract B can call back into Contract A. If Contract A hasn’t updated its balances yet, the attacker can withdraw repeatedly.

What makes re-entrancy dangerous is its simplicity. No sophisticated math or oracle manipulation required. Just understand how execution order works and exploit the timing window. This is why auditors check for it first.

How a Re-Entrancy Attack Works

Here’s a vulnerable withdrawal contract:

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

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

function withdraw() external {
    uint256 userBalance = balances[msg.sender];
    require(userBalance > 0, "No balance to withdraw");

    // VULNERABILITY: External call happens BEFORE state update
    (bool success, ) = msg.sender.call{value: userBalance}("");
    require(success, "Transfer failed");

    // State update happens AFTER external call
    balances[msg.sender] = 0;
}

}

The flow: user calls withdraw(), contract checks balance, sends Ether via external call, then sets balance to zero. The attack happens in that gap.

An attacker deploys a malicious contract that calls withdraw() on VulnerableBank. When VulnerableBank sends Ether, it triggers the fallback function. That fallback immediately calls withdraw() again—before the balance gets set to zero. The balance check still passes, so the attacker gets another withdrawal. This repeats until the contract drains:

// ATTACKER'S CONTRACT
contract Attacker {
    VulnerableBank public bank;
    address public owner;

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

function attack() external payable {
    require(msg.value >= 1 ether);
    bank.deposit{value: 1 ether}();
    bank.withdraw();
}

receive() external payable {
    if (address(bank).balance >= 1 ether) {
        bank.withdraw();
    }
}

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

}

Attacker funds their contract, deposits, then calls withdraw. When the bank sends Ether, the fallback triggers and calls withdraw again. Each cycle extracts another 1 ether until empty. The balances[msg.sender] = 0 line never executes because the function keeps getting re-called.

The DAO Hack: A $60 Million Case Study

In June 2016, The DAO—a decentralized venture capital fund—lost about 3.6 million Ether, worth $60 million then (over $6 billion at 2024 prices). The vulnerability was textbook re-entrancy.

The DAO’s splitDAO function let token holders create a “child DAO.” It transferred Ether first, then updated the balance. Attacker exploited this exact ordering to re-enter and drain funds repeatedly.

The hack split Ethereum into ETH and ETC (Ethereum Classic) after a controversial hard fork to recover stolen funds. It launched the entire DeFi security industry. Every audit today checks for re-entrancy. Every Solidity developer learns about The DAO.

The irony: the vulnerability was identified and a fix proposed before the hack. The attack succeeded not because re-entrancy was unknown, but because the team didn’t implement known solutions.

How to Prevent Re-Entrancy Attacks

Three patterns have become industry standards.

Check-Effects-Interactions Pattern

Restructure code so state changes happen before external calls:

// SECURE CONTRACT - Using CEI Pattern
contract SecureBank {
    mapping(address => uint256) public balances;

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

function withdraw() external {
    uint256 userBalance = balances[msg.sender];
    require(userBalance > 0, "No balance to withdraw");

    // EFFECT: Update state BEFORE external call
    balances[msg.sender] = 0;

    // INTERACTION: External call happens AFTER state update
    (bool success, ) = msg.sender.call{value: userBalance}("");
    require(success, "Transfer failed");
}

}

Now when an attacker re-enters, the balance check fails because it’s already zero. Attack fails instantly.

Re-Entrancy Guards

OpenZeppelin provides a modifier using a mutex:

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

contract SecureBankV2 is ReentrancyGuard { mapping(address => uint256) public balances;

function withdraw() external nonReentrant {
    uint256 userBalance = balances[msg.sender];
    require(userBalance > 0, "No balance to withdraw");

    balances[msg.sender] = 0;

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

}

The nonReentrant modifier sets a flag before execution, clears it after. If anyone tries to re-enter, it reverts. This is a safety net when CEI isn’t sufficient.

Pull Payments Over Push Payments

Avoid sending Ether directly. Use a withdrawal pattern:

// SECURE CONTRACT - Pull Payment Pattern
contract PullPaymentBank {
    mapping(address => uint256) public balances;
    mapping(address => uint256) public pendingWithdrawals;

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

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

    balances[msg.sender] = 0;
    pendingWithdrawals[msg.sender] += amount;
}

function claimWithdrawal() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "Nothing to claim");

    pendingWithdrawals[msg.sender] = 0;
    payable.transfer(amount);
}

}

User initiates withdrawal, which records their claim. They call claimWithdrawal() separately to receive funds. Attacker can re-enter claimWithdrawal(), but there’s nothing to exploit—the amount gets set to zero before transfer.

Detecting Re-Entrancy Vulnerabilities

Static Analysis Tools: Slither from Trail of Bits automatically detects re-entrancy. It flags patterns where external calls precede state updates. Run it first in any audit.

Manual Code Review: Any function making an external call then modifying related state is potentially vulnerable. Look for balance updates, allowance changes, or ownership transfers after external calls.

Interaction Ordering: Trace the exact sequence—what happens first, second, what if someone re-enters between? If state changes happen after external calls and affect withdrawal eligibility, you have a vulnerability.

Untrusted Contract Interactions: When your contract calls externally deployed contracts, attack surface expands. Integrate with arbitrary tokens or DeFi protocols needs extra scrutiny.

The uncomfortable truth: re-entrancy still appears in 2024 despite being documented for years. Projects large and small lose funds to this exact pattern. We know how to prevent it. The problem is skipping security basics under shipping pressure, or implementing custom patterns without understanding implications.

Frequently Asked Questions

Can re-entrancy attacks happen with ERC-20 tokens?

Yes. The classic example uses native Ether, but the same vulnerability applies with token.transfer(). If you update balances after transfer, an attacker builds a malicious token that calls back during transfer. Fix is identical: CEI or re-entrancy guards.

Are only fallback functions vulnerable?

No. Any external call creates a re-entrancy point, including calls to contracts you control. The vulnerability isn’t limited to fallbacks—it applies whenever your contract calls an external address that can execute code calling back in.

Does using .transfer() instead of .call() prevent re-entrancy?

Historically, .transfer() was recommended because it forwarded 2300 gas—insufficient for re-entrancy. However, this stipend changed with Istanbul hard fork and may change again. It’s not reliable. Use CEI and re-entrancy guards instead.

Conclusion

Re-entrancy attacks persist because they’re simple to execute and devastating in impact. Prevention: update state before external calls. Add re-entrancy guards for complex functions. Consider pull payments when architecture allows.

But here’s the thing: we’re in 2024 and developers still ship vulnerable code. The DAO was almost a decade ago. Patterns are documented, tools exist, audits are available. The gap isn’t knowledge—it’s discipline.

Every time you write a function touching external contracts, pause and ask: what happens if this gets called back immediately? If you can’t answer confidently, don’t deploy.

CEI, re-entrancy guards, pull payments—these aren’t optional. They’re the baseline cost of building in this space. Ignore them and you’re not building a protocol. You’re building a target.

img

Established author with demonstrable expertise and years of professional writing experience. Background includes formal journalism training and collaboration with reputable organizations. Upholds strict editorial standards and fact-based reporting.

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts