Mastering Reentrancy Security
Introduction
In the early phase of the second quarter of the year 2023 (specifically, April 4), a liquidity protocol named Sentiment suffered a loss of around $1 million. The loss was caused by a reentrancy vulnerability in the protocol’s smart contract, which an attacker exploited.
To jog your memory, let’s rewind to the year 2016 when Ethereum faced a significant hack known as the DAO hack. During this incident, approximately $60 million was lost due to a reentrancy vulnerability within the contract.
As you can see, reentrancy vulnerability is an issue that requires serious attention. This is exactly why the purpose of this article is to provide a thorough explanation of reentrancy, how it occurs, various types of reentrancy, and mitigation methods.
What is Reentrancy?
Reentrancy in Solidity refers to a situation where a contract can be called multiple times before its initial execution is completed. This can lead to unexpected behaviours and vulnerabilities that attackers can take advantage of if not properly managed.
To simply illustrate how a reentrancy attack occurs, let’s look at the example below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
function getBalance() public view returns (uint256) {
return balances[msg.sender];
}
}
contract AttackerContract {
VulnerableContract public vulnerableContract;
constructor(address _vulnerableContract) {
vulnerableContract = VulnerableContract(_vulnerableContract);
}
function attack() public payable {
// Deposit a small amount
vulnerableContract.deposit{value: 1 ether}();
// Start reentrancy attack
vulnerableContract.withdraw(1 ether);
}
receive() external payable {
// Reentrant function
vulnerableContract.withdraw(1 ether);
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Looking at the example above, we have two contracts: VulnerableContract
and AttackerContract
. The VulnerableContract
is exposed to a reentrancy attack due to a flaw in the way withdrawal works. When a user calls the withdraw function, it transfers the requested amount to the user’s address before updating the balance. This allows an attacker to repeatedly call the withdraw function from their contract (AttackerContract) before the balance is updated.
The AttackerContract
exploit this vulnerability by initiating the attack. It first deposits a small amount into the VulnerableContract
, and then immediately triggers a reentrancy attack by calling the withdraw function and recursively invoking it within the receive function.
Types of Reentrancy Attack
There are various ways in which a smart contract can experience reentrancy, and we will be discussing them:
- Read-only Reentrancy:
Read-only reentrancy is a type of reentrancy where the view function in a contract is reentered, and usually, it’s not protected because it doesn’t change the contract’s data. However, if the contract’s data is messed up, it might give incorrect results. The main difference between read-only reentrancy and normal reentrancy in Solidity is that read-only reentrancy occurs when a view function is reentered, while normal reentrancy occurs when a regular function is reentered.
In a read-only reentrancy attack, an attacker triggers a function in a target contract that calls an external contract. Then, the attacker quickly triggers the same function again before the external call completes. This can be done through another contract or a callback function.
Since the original function is still running when reentered, the attacker can read the target contract’s state before the external call updates it. This gives an attacker an edge when performing an attack.
Here is an example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint256) public balances;
bool private locked;
constructor() {
locked = false;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
require(!locked, "Contract is locked");
locked = true; // Lock the contract
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
locked = false; // Unlock the contract
}
// This function is supposed to be read-only, but it can be exploited
function getBalance(address account) external view returns (uint256) {
// Malicious contract can exploit this function
return balances[account];
}
}
Looking at the example, the vulnerable contract allows users to deposit and withdraw funds. But there is a problem: it has a flaw where a malicious contract can repeatedly withdraw funds while exploiting the read-only getBalance
function. This can lead to unauthorized changes to the contract’s state and the potential loss of funds.
- Single function Reentrancy:
Single-function reentrancy is a type of attack that involves a situation in which the attacker aims to withdraw funds from the contract more than once. They achieve this by calling the same function over and over, even before the previous calls are complete. This creates a loop where the attacker can repeatedly drain funds from the contract.
This attack can be straightforward to understand and prevent, and it stresses the importance of ensuring that a function’s execution is completed before allowing further calls to it.
For clarity, let’s look at the example below:
// SPDX-License-Identifier: MIT
// Vulnerable contract
pragma solidity ^0.8.0;
contract VulnerableReentrancy {
mapping(address => uint256) private balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Vulnerable point: External call before updating the balance
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
}
The vulnerable contract contains a function called withdraw
, which is exposed to a single function reentrancy attack. The contract has a balances
mapping that tracks the balances of different addresses. When an address calls the withdraw
function and specifies an amount to withdraw, the contract transfers the specified amount to the caller’s address before updating their balance. This order of execution allows an attacker to repeatedly call the withdraw function before the balance is correctly updated, potentially draining more funds from the contract than they have.
- Cross-Chain Reentrancy:
Cross-chain reentrancy is a type of reentrancy attack that occurs when a contract on one blockchain calls a contract on another blockchain, and the contract on the second blockchain calls back to the contract on the first blockchain. This can be exploited by the attacker to drain the funds from the contract on the first blockchain.
Cross-chain reentrancy attacks are a serious threat to the security of cross-chain applications. Developers of cross-chain applications should carefully consider the risks of cross-chain reentrancy attacks and take steps to mitigate these risks.
Here is an example of a contract vulnerable to cross-chain reentrancy:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Vulnerable point: external contract call before updating balance
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
}
The withdraw
function in the contract above allows for an external contract call (msg.sender.call
) before updating the user’s balance, creating a vulnerable point for reentrancy.
If another contract on a different blockchain interacts with this contract and repeatedly calls the withdraw
function before the balances[msg.sender] -= amount
line is executed, it can lead to cross-chain reentrancy attacks and potentially drain the contract’s funds.
- Cross-function Reentrancy:
Cross-function reentrancy occurs when a function within a contract keeps calling another function within the same contract before the first function completes. This creates a loop where functions interact with each other in an unsafe manner. Sometimes this vulnerability arises when multiple functions within a contract share and update the same data without proper guards.
Look at this simple example below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint) balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function withdrawAll() public {
uint balance = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
}
The contract lacks proper synchronization of state changes and external calls, making it liable to cross-function reentrancy attacks. The vulnerability lies in the fact that the state variable balances
is updated after the call operation in both withdraw
and withdrawAll
. This allows an attacker to reenter the contract by calling one function repeatedly before the call in the previous invocation completes.
- Cross-Contract Reentrancy:
Cross-contract reentrancy occurs when the state of one contract is used in another contract, yet this state isn’t entirely updated before being invoked. For a clearer understanding, look at the example below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}
The contract has a withdraw
function that allows users to retrieve funds from their balance. However, the function lacks a guard against reentrancy attacks, enabling malicious external contracts to repeatedly invoke the function before previous calls complete.
Mitigation Techniques
Knowing how to protect your smart contract from reentrancy attacks is crucial. Below are some anti-reentrancy techniques and best practices you need to implement to safeguard your contracts:
- Checks, Effects, and Interactions (CEI):
CEI is a basic technique used to minimize the damage of reentrancy attacks. How this technique works is that it checks and tries to update all states before calling for external contracts. To learn more about CEI you can look at the solidity docs.
- Keep Your Code Simple:
Writing clean and simple code can significantly reduce the chances of your contracts or functions having bugs and prevent unexpected errors. Although your contract codes may be large and bogus, going through them, simplifying them, and reviewing them can help identify any flaws in the code.
- Reentrancy Guard:
A reentrancy guard prevents the execution of multiple functions at the same time. By implementing a reentrancy guard, you prevent attackers from calling your contract or function multiple times. You can achieve this by using a reentrant function modifier. A suggested method is to utilize the nonReentrant()
modifier available in the OpenZeppelin library.
- Pull Payment Strategy:
This involves setting up an intermediary whereby the paying contract doesn’t directly interact with the receiver account. Instead, the receiver account is responsible for withdrawing its payments. This is a good method to secure end-to-end transactions. To implement this strategy in your contract, it’s recommended to utilize the PullPayment contract from the Openzepplin library.
- Pausable or Emergency Stop Pattern:
This pattern allows you to quickly pause any withdrawals as soon as you detect any suspicious activity in your contract. Only an authorized account can initiate this pausable feature. For better implementation, use the pausable contract from Openzeppelin.
- Smart Contract Code Analysis Tools:
Code analysis tools are essential for preventing reentrancy vulnerabilities in smart contracts. These tools scan the codebase for potential vulnerabilities and provide recommendations for remediation. Some examples of code analysis tools for reentrancy vulnerabilities are Slither and Mythril.
- Smart Contract Audit:
A smart contract audit is a security review of a smart contract’s code. This helps identify and fix any potential vulnerabilities that could be exploited by attackers. By conducting a smart contract audit before the contract is deployed to the mainnet, developers can reduce the risk of their contract being hacked.
Conclusion
Reentrancy vulnerabilities in smart contracts have led to significant losses in the past. Reentrancy attacks involve reentering a contract before its execution finishes, enabling malicious manipulation. The attack types discussed included read-only, single-function, cross-chain, cross-function, and cross-contract reentrancy. Developers can guard against reentrancy by using techniques such as CEI, applying simplicity in code, reentrancy guards, pull payment strategy, pausable pattern, utilizing analysis tools, and conducting smart contract audits.
These measures collectively strengthen smart contract security by reducing reentrancy vulnerabilities and potential losses.