Understanding Reentrancy in smart contracts
What is reentrancy vulnerability?
This is a common vulnerability in smart contracts where contracts can be exploited by making a function repeatedly perform an action without updating or making the required state changes.
Imagine being able to withdraw money from an A.T.M repeatedly until the money in it finishes, simply because the A.T.M was not able to update your bank account after every withdrawal. This might happen due to network issues or a bug in its code.
Insane right!!!
This is a situation unfortunately, that commonly happens in smart contracts.
When a vulnerable withdraw function in a smart contract is called repeatedly before it is able to update the state of the its balance, the contract will be drained of its funds before its balance state is updated.
Classic example of a reentrancy attack
- Victim contract
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.6;
contract Victim{
mapping (address=>uint256) balances;
function deposit() public payable{
require(msg.value>0, "Please deposit some ETH");
balances[msg.sender] += msg.value;
}
function withdraw() public{
uint256 bal = balances[msg.sender];
require(bal>0, "the user did not deposit that amount in this contract");
(bool sent, ) = msg.sender.call{value: bal}("");
balances[msg.sender] = 0;
require(sent, "Failed to send Ether");
}
}
- Attacker Contract
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.6;
import "./contract.sol";
contract Attacker{
Victim public victim;
constructor(address _victim) {
victim = Victim(_victim);
}
receive() external payable{
if(address(victim).balance>1 ether){
victim.withdraw();
}
}
function attack() public payable {
require(msg.value == 1 ether, "Send the required attack amount");
victim.deposit{value: 1 ether}(); // Deposit some Ether to the Store contract
victim.withdraw();
}
}
Here the Attacker contract exploits the victim contract by first depositing some Eth to the Victim contract, which like in the case of the A.T.M is like depositing money in the bank and getting a card so that you can be eligible to withdraw your money through the machine.
The Attacker contract then places a withdrawal from the Victim contract.
On receival of the withdrawal, the Attacker contract places another withdrawal using the fallback function and does so repeatedly before its balance is updated on the blockchain, hence withdrawing all the funds in the Victim contract.
In the Victim contract, the line of code responsible for sending Eth to the Attacker contract is
(bool sent, ) = msg.sender.call{value: bal}("");
What this line does is that it allows for contract to contract interaction. The withdraw function interacts with the attacker contract through the call() method and sends funds through the value property to the attacker function.
For the attacker contract to receive this funds, the execution of the withdraw function is temporarily paused and then handed over to the attacker contract which has the receive fallback function that is triggered immediately eth is sent to the contract.
receive() external payable{
if(address(victim).balance>1 ether){
victim.withdraw();
}
}
The receive fallback function can now make recursive calls to the withdraw function since it’s execution is temporarily paused.
Why does the withdraw function have to pause it’s execution and wait for the receive function to get executed first?
The Ethereum virtual machine (EVM) is single threaded.
💡
Threads are the most basic units of a process that can be handled independently.
The EVM processes transaction within a block one after the other, which allows each transaction to operate with a shared global state. Only one transaction can modify this state per time, hence preventing the withdraw function from modifying the state of attackers balance until the receive fallback function is done executing.
With this knowledge, different strategies have been employed to reduce this vulnerability.
The Checks, Effects, Interactions pattern (CEI pattern)
This pattern enforces a specific order of operations within a smart contract function, to mitigate reentrancy vulnerabilities.
Checks -
This stage involves making all the necessary verifications and checks before making state changes.
e.g: Ensuring that sufficient funds is available for a transaction.
require(bal > 0, "You do not have sufficient balance to continue this transaction");
Effects -
This is the stage where the state of the contract is modified based on the checks.
e.g: Updating balance
balances[msg.sender] = 0;
Interactions -
This stage involves any external interactions the contract needs to perform.
e.g: Calling other contracts
(bool sent, ) = msg.sender.call{value: bal}("");
Implementing the CEI pattern in the Victim contract
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.6;
contract Victim{
mapping (address=>uint256) balances;
function deposit() public payable{
require(msg.value>0, "Please deposit some ETH");
balances[msg.sender] += msg.value;
}
function withdraw() public{
uint256 bal = balances[msg.sender];
require(bal>0, "the user did not deposit that amount in this contract");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
}
This pattern reduces the vulnerability in this contract as the internal state of the balance is updated before the call to the attacker contract is made.
It is also important to note that the effectiveness of this pattern can diminish when dealing with multiple external entities in a single transaction. Ensuring the proper order of checks, effects and interactions throughout the program flow can become challenging. Which brings us to another approach.
Reentrancy Guard Modifiers
Reentrancy guard modifiers provide a safe way of using the call method in solidity.
Implementing a custom reentrancy guard modifier in the Victim contract
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.6;
contract Victim{
mapping (address=>uint256) balances;
bool private reentrant = false;
modifier nonReentrant(){
require(!reentrant, "Reentrancy is not allowed");
reentrant = true;
_;
reentrant = false;
}
function deposit() public payable{
require(msg.value>0, "Please deposit some ETH");
balances[msg.sender] += msg.value;
}
function withdraw() public nonReentrant{
uint256 bal = balances[msg.sender];
require(bal>0, "the user did not deposit that amount in this contract");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
}
Here, a private state variable "reentrant" is declared and set to false. The nonReentrant modifier makes sure that this variable is set to true and doesn't change until the withdraw function is done with it's execution. Since the modifier also checks to make sure "reentrant" is false before it allows the function to run, a malicious contract can't reenter the withdraw function as the transaction will be reverted.
OpenZeppelin also provides a Reentrancy guard that can be used by importing the library,
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
inheriting the ReentrancyGuard contract, and using it's nonReentrant modifier just like the custom nonReentrant modifier used in the last code example.
Wrap Up
The lack of proper understanding of the "call" function when used in solidity puts a smart contract at risk of reentrancy, hence should not be taken lightly.
Other methods like "transfer" can be used instead of "call" in some situation. The tradeoffs must be well understood in those situations.
Thanks for reading. Feel free to leave a reaction and most especially, your thoughts about reentrancy vulnerability in smart contracts. ✌🏾
Don't forget to subscribe to my newsletter and be the first to read my next blog.
Subscribe to my newsletter
Read articles from Oladipo Evangel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Oladipo Evangel
Oladipo Evangel
Blockchain & Smart Contract Developer.