Reentrancy attacks remain one of the most devastating vulnerabilities in smart contract security. Despite being well-documented since the infamous DAO hack of 2016, they continue to plague DeFi protocols and other Ethereum applications, resulting in millions of dollars in losses. In this article, I'll break down what reentrancy attacks are, how they work, and most importantly, how to protect your smart contracts against them.
What is a Reentrancy Attack?
A reentrancy attack occurs when a function makes an external call to another untrusted contract before it resolves its own state. If the untrusted contract calls back into the original function, it can repeatedly execute the code before the first invocation is complete, potentially draining funds or manipulating state in unexpected ways.
The vulnerability exists because of how Ethereum's execution model works. When a contract calls an external function, the execution context shifts to the called contract, allowing it to perform actions before returning control to the original caller.
// Vulnerable contract
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// This external call happens before updating the state
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
// State update happens after the external call
balances[msg.sender] -= _amount;
}
}
In the example above, the withdraw
function sends Ether to the user before updating their balance.
If the recipient is a malicious contract, it can call back into withdraw
before the balance is updated,
allowing it to withdraw more than its actual balance.
The Anatomy of a Reentrancy Attack
Let's examine how an attacker would exploit the vulnerable contract:
// Malicious contract
contract Attacker {
VulnerableBank public vulnerableBank;
uint public amount;
constructor(address _vulnerableBankAddress) {
vulnerableBank = VulnerableBank(_vulnerableBankAddress);
amount = 1 ether;
}
// Function to start the attack
function attack() external payable {
require(msg.value >= amount, "Send more ETH");
// Deposit into the vulnerable contract
vulnerableBank.deposit{value: amount}();
// Trigger the withdraw function, which will call the fallback
vulnerableBank.withdraw(amount);
// At this point, we've drained more than we deposited
console.log("Attack completed. Balance:", address(this).balance);
}
// Fallback function that gets triggered when receiving ETH
receive() external payable {
// If there's still ETH in the vulnerable contract, withdraw again
if (address(vulnerableBank).balance >= amount) {
vulnerableBank.withdraw(amount);
}
}
}
The attack flow works like this:
- The attacker deploys the malicious contract and calls
attack()
with 1 ETH. - The malicious contract deposits 1 ETH into the vulnerable bank.
- The malicious contract calls
withdraw(1 ETH)
on the vulnerable bank. - The vulnerable bank sends 1 ETH to the malicious contract, triggering its
receive()
function. - Before the vulnerable bank updates the attacker's balance, the
receive()
function callswithdraw(1 ETH)
again. - Steps 4-5 repeat until the vulnerable bank is drained of funds.
- Finally, all the nested calls complete, and the balances are updated only once per reentrancy.
Types of Reentrancy Attacks
Reentrancy vulnerabilities come in several forms:
1. Single-Function Reentrancy
This is the classic case we just examined, where a single function is reentered multiple times.
2. Cross-Function Reentrancy
In this case, the attacker reenters a different function that shares state with the original function.
// Function A makes an external call
function withdrawAll() public {
uint amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
// Function B shares state with Function A
function transfer(address to, uint amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
An attacker could call withdrawAll()
, and during the external call, reenter the contract
through transfer()
, manipulating the shared state before the first function completes.
3. Cross-Contract Reentrancy
This occurs when the reentrancy attack targets multiple contracts that interact with each other.
Preventing Reentrancy Attacks
There are several well-established patterns to prevent reentrancy attacks:
1. The Checks-Effects-Interactions Pattern
This is the most fundamental defense against reentrancy. Always follow this sequence:
- Checks: Validate all preconditions
- Effects: Update the contract's state
- Interactions: Interact with external contracts
// Secure implementation
function withdraw(uint _amount) public {
// Checks
require(balances[msg.sender] >= _amount, "Insufficient balance");
// Effects
balances[msg.sender] -= _amount;
// Interactions
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
2. Reentrancy Guards
Use a mutex to prevent reentrancy:
// Using a reentrancy guard
contract SecureBank {
mapping(address => uint) public balances;
bool private locked;
modifier nonReentrant() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}
3. Use OpenZeppelin's ReentrancyGuard
Instead of implementing your own guard, use the battle-tested implementation from OpenZeppelin:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}
4. Consider Using the Pull Pattern
Instead of directly sending funds (push), let users withdraw their funds (pull):
// Using the pull pattern
contract SecureBank {
mapping(address => uint) public balances;
mapping(address => uint) public pendingWithdrawals;
// Request a withdrawal
function requestWithdrawal(uint _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
pendingWithdrawals[msg.sender] += _amount;
}
// Complete the withdrawal
function completeWithdrawal() public {
uint amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No pending withdrawal");
// Clear the pending withdrawal before sending
pendingWithdrawals[msg.sender] = 0;
// Send the funds
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Real-World Examples
Reentrancy attacks have been responsible for some of the largest hacks in DeFi history:
The DAO Hack (2016)
The original reentrancy attack that led to the Ethereum hard fork, resulting in the creation of Ethereum Classic. The attacker stole approximately 3.6 million ETH, worth about $60 million at the time.
Cream Finance (2021)
Cream Finance suffered a $130 million exploit due to a complex reentrancy vulnerability in their flash loan feature.
Fei Protocol (2022)
A cross-contract reentrancy vulnerability in Fei Protocol's Rari Capital Fuse pools led to a $80 million loss.
Conclusion
Reentrancy attacks remain a significant threat to smart contract security. By understanding how they work and implementing proper safeguards, developers can protect their contracts from these devastating exploits.
Always remember these key principles:
- Follow the Checks-Effects-Interactions pattern
- Use reentrancy guards for sensitive functions
- Consider the pull pattern for fund withdrawals
- Be aware of cross-function and cross-contract reentrancy
- Always get your contracts audited by security professionals
By implementing these best practices, you can significantly reduce the risk of reentrancy vulnerabilities in your smart contracts and build more secure decentralized applications.