How to Protect Against Reentrancy Attacks in Solidity
Smart contracts are supposed to be unstoppable, self-executing agreements, but one mistake in their code can lead to millions in losses. Reentrancy attacks are among the most infamous vulnerabilities in Solidity, and they’ve been exploited in some of the biggest crypto heists. Understanding how to guard against them isn’t just good practice—it’s a necessity.
What Is a Reentrancy Attack?
A reentrancy attack happens when a smart contract calls an external contract before updating its own state. If the external contract is malicious, it can call back into the original contract before the first function finishes executing. This allows an attacker to withdraw more funds than they should be able to, often draining the contract entirely.
Think of it like withdrawing money from an ATM, but the bank doesn’t update your account balance right away. If the machine lets you keep withdrawing before the balance changes, you can empty the account, even if you started with just a small amount.
How Reentrancy Attacks Happen
The Unprotected Withdraw Pattern
One of the most common mistakes is sending Ether before updating balances. Here’s a vulnerable function:
function withdraw() public {
require(balances[msg.sender] > 0, "No funds to withdraw");
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
The problem? The contract sends money before setting balances[msg.sender]
to zero. A malicious contract can repeatedly call withdraw()
before the balance updates, pulling out far more than allowed.
The Attacking Contract
A malicious contract can exploit this with:
contract Attack {
VulnerableContract public vulnerable;
constructor(address _vulnerable) {
vulnerable = VulnerableContract(_vulnerable);
}
fallback() external payable {
if (address(vulnerable).balance > 0) {
vulnerable.withdraw();
}
}
function attack() external payable {
vulnerable.deposit{value: msg.value}();
vulnerable.withdraw();
}
}
Every time the attacker gets funds, it calls withdraw()
again before the balance updates. This cycle repeats until the original contract is drained.
How to Prevent Reentrancy Attacks
Use the “Checks-Effects-Interactions” Pattern
This method ensures that a contract’s state is updated before interacting with external contracts. Fix the withdrawal function like this:
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Now, even if an attacker re-enters the contract, their balance is already set to zero. No more infinite withdrawals.
Use Reentrancy Guards
The OpenZeppelin ReentrancyGuard
modifier prevents functions from being called multiple times in a single transaction.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureContract is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
This makes sure withdraw()
cannot be called again until the first execution finishes.
Avoid Using call
for Ether Transfers
call
is a low-level function that doesn’t limit gas, which can lead to unexpected reentrancy. Using transfer
or send
instead makes transactions fail if they use too much gas, limiting attack possibilities.
payable(msg.sender).transfer(amount);
This is safer, but keep in mind that gas limits may change in the future, so always check Solidity’s latest best practices.
Use Pull Payments Instead of Push Payments
Instead of contracts sending money automatically (push), let users manually withdraw their funds (pull).
function claimPayment() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds to withdraw");
balances[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
Since users initiate withdrawals themselves, reentrancy risks are reduced.
Limit Gas to External Calls
If you must use call
, limit the gas:
(bool success, ) = msg.sender.call{value: amount, gas: 2300}("");
This makes it harder for attackers to execute reentrancy, though it’s not a foolproof solution.
Consider Using a Smart Contract Audit
Even experienced developers miss vulnerabilities. A security audit can catch issues before they become costly exploits. Tools like Slither, MythX, and CertiK help automate vulnerability detection.
Real-World Reentrancy Attacks
The DAO Hack
The 2016 DAO attack was one of the biggest Ethereum security breaches. A reentrancy exploit allowed hackers to drain millions of dollars from the DAO’s smart contract before Ethereum’s developers stepped in with a hard fork.
Final Thoughts
Reentrancy attacks are sneaky but avoidable. The key is to update contract states before making external calls, use reentrancy guards, and follow security best practices. If a contract handles funds, assume someone will try to break it. Writing secure Solidity code isn’t just about functionality—it’s about protecting assets from those looking for loopholes. Keep your contracts tight, test them well, and always stay ahead of potential threats.