Reentrancy Ethereum Smart Contract Best Practices
Table of contents
Smart contracts are computer programs that run on the Ethereum blockchain. They are designed to execute automatically and securely without the need for intermediaries. However, despite their potential benefits, smart contracts are not immune to security vulnerabilities. One such vulnerability is reentrancy, which occurs when an external call is made to an untrusted contract that invokes a function in the calling contract before the initial call is completed. In this article, we will discuss how reentrancy attacks work and provide some best practices for preventing them.
What is Reentrancy?
Reentrancy is a critical vulnerability that can impact Ethereum smart contracts. It is a type of attack in which an external contract can call a function within a smart contract multiple times before the initial call is completed, leading to unexpected behavior and potential security vulnerabilities. Reentrancy attacks can result in the loss of funds or the manipulation of the contract state, making it essential for smart contract developers to follow best practices to prevent such attacks.
How Does a Reentrancy Attack Work?
The attack involves a malicious contract calling a vulnerable contract that allows external calls to be made to it. The malicious contract can then repeatedly call the vulnerable contract’s functions, which can result in unexpected behaviors and potential security vulnerabilities
To understand how a reentrancy attack works, let’s consider an example. Imagine we have a simple smart contract that allows users to withdraw funds from their accounts. The contract has a “withdraw” function that transfers funds from the contract to the user’s account. The function looks something like this:
contract SimpleBank {
mapping(address => uint) balances;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}
}
The withdraw function allows users to withdraw funds from their accounts. However, if an attacker calls the withdraw
function and passes a malicious contract as the destination address, the attacker’s contract can repeatedly execute the withdraw
function before the previous invocation has been completed, resulting in a reentrancy attack.
To prevent this vulnerability, it is essential to follow best practices when writing smart contracts.
Reentrancy Ethereum Smart Contract Best Practices Let’s explore some of the best practices for preventing reentrancy attacks in Ethereum smart contracts.
1. Use the Checks-Effects-Interactions Pattern For example, consider the following vulnerable code:
function transfer(address recipient, uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
recipient.transfer(amount);
}
In this code, the transfer function first checks that the sender has sufficient funds, then modifies the sender’s balance, and finally transfers the funds to the recipient. However, this code is vulnerable to reentrancy attacks because the external call to the recipient contract is made before the sender’s balance is updated.
To fix this vulnerability using the CEI pattern, we can modify the code as follows:
function transfer(address recipient, uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
if (!_transfer(recipient, amount)) {
balances[msg.sender] += amount;
}
}
function _transfer(address recipient, uint amount) internal returns (bool) {
(bool success, ) = recipient.call{value: amount}("");
return success;
}
In this code, we have separated the external call to the recipient contract into a separate function_transfer
that is only called after all checks and state modifications have been made. If the external call is unsuccessful, the sender's balance is rolled back to its original state.
2. Limit the Use of External CallsUse
To reduce the risk of reentrancy attacks, it is important to limit the use of external calls in smart contracts. Only make external calls when necessary and ensure that the called contract is trusted and secure.
For example, consider the following vulnerable code:
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
msg.sender.call{value: amount}("");
}
In this code, the withdraw
the function checks that the sender has sufficient funds, modifies the sender’s balance, and then transfers the funds to the sender using an external call. However, this code is vulnerable to reentrancy attacks because the external call can be made before the sender’s balance is updated.
To fix this vulnerability, we can use the transfer function instead of an external call:
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
msg.sender.transfer(amount);
}
In this code, we have replaced the external call with the transfer
function, which transfers the funds directly to the sender’s account without invoking any external contracts
3. Mutexes
Mutexes are locks that prevent multiple threads from accessing the same resource at the same time. In the context of smart contracts, mutexes can be used.
here’s an example of using mutexes:
pragma solidity ^0.8.0;
contract ReentrancyExample {
mapping (address => uint) private balances;
mapping (address => bool) private locked;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// Acquire the lock for this user
require(!locked[msg.sender], "Withdraw already in progress");
locked[msg.sender] = true;
// Transfer the funds
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
// Update the balance and release the lock
balances[msg.sender] -= _amount;
locked[msg.sender] = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function getBalance(address _account) public view returns (uint) {
return balances[_account];
}
}
In this example, we use a locked
mapping to keep track of whether a withdrawal is already in progress for a given user. Before executing a withdrawal, we first check whether the lock is available. If it is, we acquire the lock and proceed with the transfer. After the transfer is complete, we update the balance and release the lock.
By using a mutex like this, we ensure that only one withdrawal can be processed at a time for each user. This reduces the risk of reentrancy attacks, because even if an attacker tries to call the withdraw
function multiple times in quick succession, they will be blocked by the lock until the previous withdrawal has been completed.
Subscribe to my newsletter
Read articles from Muhindo Galien directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Muhindo Galien
Muhindo Galien
I am a Software Engineer with a strong focus on full-stack web3 dev #open_to_work