Understanding Reentrancy Attacks in Web3 Smart Contracts
A detailed guide to reentrancy, one of the most notorious and destructive vulnerabilities in smart contract history. Learn how it works, why it's so dangerous, and how to prevent it.

In the world of Web3 security, few vulnerabilities are as famous or as feared as reentrancy. This was the attack vector behind the infamous 2016 hack of "The DAO," an event that led to the theft of 3.6 million ETH and resulted in a contentious hard fork of the Ethereum blockchain, creating Ethereum and Ethereum Classic as two separate chains.
Understanding reentrancy is a rite of passage for any serious smart contract developer or security auditor. It is a subtle but devastating bug that preys on the way smart contracts handle external calls. This guide will provide a deep dive into reentrancy, explaining the mechanics of the attack and the essential patterns required to defend against it.
What is Reentrancy?
A reentrancy attack happens when an external call from a victim contract to a malicious contract allows the malicious contract to call back (re-enter
) into the victim contract before the original function call has completed its execution.
The core of the problem lies in the order of operations. A vulnerable contract typically performs an external call (like sending Ether) before it updates its own internal state (like a user's balance). This creates a window of opportunity for an attacker to drain the contract's funds.
The Classic Attack Scenario
Let's imagine a simple Bank
contract that allows users to deposit and withdraw Ether.
// VULNERABLE CODE - DO NOT USE
contract Bank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// The vulnerability is here:
// 1. External call is made...
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether");
// 2. ...before the state is updated.
balances[msg.sender] = 0;
}
}
This withdraw
function seems logical at first glance, but it is fatally flawed.
The Attacker's Contract:
An attacker would create a malicious contract with a special receive()
or fallback()
function. This function is automatically triggered whenever the contract receives Ether.
// ATTACKER'S CONTRACT
import "./Bank.sol";
contract Attacker {
Bank public victimBank;
constructor(address _victimBankAddress) {
victimBank = Bank(_victimBankAddress);
}
// 1. Attacker deposits 1 ETH into the Bank
function attack() public payable {
victimBank.deposit{value: 1 ether}();
victimBank.withdraw();
}
// 4. This function is called when the Bank sends Ether.
// It re-enters the Bank's withdraw function.
receive() external payable {
// As long as the Bank has more Ether, re-enter the withdraw function
if (address(victimBank).balance >= 1 ether) {
victimBank.withdraw();
}
}
}
The Attack Sequence:
- The attacker deploys their
Attacker
contract and calls theattack()
function, depositing 1 ETH into theBank
. Their balance in theBank
is now 1 ETH. - The
attack()
function then callsvictimBank.withdraw()
. - The
Bank.withdraw()
function checks the attacker's balance (1 ETH) and proceeds to send them 1 ETH via the.call
method. - The transfer of Ether to the
Attacker
contract triggers itsreceive()
function. - The Re-entry: Inside the
receive()
function, theAttacker
contract callsvictimBank.withdraw()
again. - The second call to
withdraw()
checks the attacker's balance. Because theBank
's state has not yet been updated (balances[msg.sender]
has not been set to 0), the balance is still 1 ETH. - The check passes, and the
Bank
sends another 1 ETH to the attacker. - This triggers the
receive()
function again, and the loop continues until theBank
contract is completely drained of its Ether. - Only after the loop is broken does the execution return to the original
withdraw
call, which finally sets the attacker's balance to 0, but it's too late.
How to Prevent Reentrancy
There are two primary methods for preventing reentrancy attacks.
1. The Checks-Effects-Interactions Pattern (The Gold Standard)
This is the most important defensive programming pattern in Solidity. It dictates that you should always structure your functions in the following order:
- Checks: First, perform all your validation checks (
require
statements). - Effects: Second, make all changes to your contract's state variables (the "effects").
- Interactions: Last, make any calls to external contracts.
By updating the state before the external call, you close the window of opportunity for the attacker.
The Secure Version of the Bank
contract:
// SECURE VERSION
contract Bank {
mapping(address => uint) public balances;
// ... deposit function ...
function withdraw() public {
// 1. Checks
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// 2. Effects
// The balance is updated BEFORE the external call.
balances[msg.sender] = 0;
// 3. Interactions
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
In this secure version, when the attacker's contract re-enters withdraw()
, the balance check require(amount > 0)
will fail because balances[msg.sender]
has already been set to 0. The attack is stopped dead in its tracks.
2. Reentrancy Guard (Mutex)
Another common pattern is to use a "mutex" (mutually exclusive), often implemented as a function modifier, that locks the contract during the execution of a function.
// SECURE VERSION WITH A REENTRANCY GUARD
contract Bank {
bool internal locked;
mapping(address => uint) public balances;
modifier nonReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function withdraw() public nonReentrant {
// ... function logic ...
}
}
- How it works: Before the function's logic is executed, the
nonReentrant
modifier setslocked
totrue
. If an attacker's contract tries to re-enterwithdraw()
, therequire(!locked)
check will fail, stopping the attack. After the original function call completes,locked
is set back tofalse
, allowing it to be called again in a new transaction. - OpenZeppelin's ReentrancyGuard: You should never write your own reentrancy guard. Always use the battle-tested and audited
ReentrancyGuard
contract from the OpenZeppelin library.
Conclusion: A Lesson in Humility
The reentrancy vulnerability is a powerful lesson in the unique challenges of smart contract development. It highlights the fact that interactions between contracts can have dangerous and unintended consequences. By rigorously applying the Checks-Effects-Interactions pattern and using trusted libraries like OpenZeppelin's ReentrancyGuard, developers can protect their protocols from this classic but devastating attack and build more secure applications for the entire Web3 ecosystem.