Smart Contracts Vulnerabilities - Reentrancy
Understanding How Reentrancy Works in Smart Contract
Introduction
Solidity is one of the most popular high-level programming languages used to write smart contracts to EVM(Ethereum Virtual Machine) compatible blockchains such as Ethereum. Smart contracts on the other hand are self-executing agreements with the terms of the agreement written directly into codes and essentially stored on the blockchain. In web3, a smart contract serves as the backend for your decentralized application (Dapp). Unlike web2, the application can be loosely coupled which means that the different components of the application are not tightly dependent on one another. This allows for greater flexibility and easier maintenance of the application. For example, if one component of the application needs to be updated or replaced, it can be done without affecting other components of the application. This is not so for Smart contracts, they are immutable which means they can not be altered once deployed to the blockchain although this is not entirely true. You can look into proxy smart contracts for further explanation. Also, smart contracts can handle and transfer assets such as cryptocurrencies, tokens, and other digital values. This stated attribute makes a smart contract very valuable. Imagine having such smart contracts with bugs and other vulnerabilities, the assets are gone to malicious attackers. This highlights the critical importance of security during the development of smart contracts, as any security issues can result in real financial losses.
Furthermore, the immutable nature of smart contracts means that once they are deployed, any security issues or vulnerabilities cannot be easily fixed without significant effort. Therefore, developers need to take the necessary measures to ensure the security and reliability of their smart contract code before deploying it on the blockchain.
In this series, I will be sharing with you some smart contract vulnerabilities and how to avoid them while writing your smart contracts.
In the first part of this series, we will be explaining reentrancy attacks and how to prevent them.
Reentrancy Attack
The reentrancy attack is one of the most popular attacks in the DeFi system. It simply happens when a smart contract makes an external call to an untrusted smart contract. This can happen when a smart contract sends ether to another smart contract using the call function. When a call function is used to make an external call to another smart contract without specifying any function to call it essentially triggers a fallback function in the smart contract being called. It is also important to note that it is compulsory for a smart contract to have either a fallback function marked payable or receive function marked payable before it can receive ether. A fallback function can have pieces of code written in it. Hence, a malicious attacker can use the fallback function to make multiple calls to any of your functions. An example is your withdrawal function until all the money in your smart contract is finished. Here is an example of this.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.0;
contract Store {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public payable {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Transaction failed");
balances[msg.sender] = 0;
}
}
The code above is of a smart contract named Store
. It employs a mapping data structure to keep track of the balance of each user in the smart contract. It has two functions namely deposit
and withdraw
; the deposit function increments the balance of each user when called while the withdraw
function is used to withdraw the user’s balance. You might have noticed where the mistake is in the withdraw function. Let us take a look at another contract to give a further explanation;
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.0;
contract Attack {
Store public store;
constructor(address _storeAddress) {
store = Store(_storeAddress);
}
fallback() external payable {
if(address(store).balance >= 1 ether) {
store.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
store.deposit{value: 1 ether}();
store.withdraw();
}
}
The contract above is named Attack
. It is used to attack the previous contract. When reading through the contract you will see that the Attack
contract accepts the Store
contract address. It does so to call some external functions on the Store
contract such as deposit
and withdraw
. In the attack
function, it calls the deposit
function of the Store
contract to deposit 1 ether into the contract since Store
requires that you have above 0 ether before you can make a withdrawal. The issue with the withdraw
function in the Store
contract is that it allows users to make withdrawals first before setting their balance to zero which leads to a big flaw in the contract. An attacker can call the withdraw
function multiple times to withdraw more than their balance in the contract because the function requires you to have above zero balance which the user still has as long as the function does not hit the state variable that is the balance of the user in the contract. The attack
function calls the withdraw
function in the Store
contract immediately which in turn triggers the fallback
function. The fallback
function calls the withdraw
function which calls the fallback
function again. This pattern repeats itself until no money is found in the Store contract.
How to Protect Smart Contracts Against Reentrancy Attack
There are a few measures to take when trying to protect smart contracts against a reentrancy attack. Here are a few;
Using Solidity’s Check-Effect-Interaction pattern:
Solidity's Check-Effect-Interaction (CEI) pattern is a programming paradigm used to enhance the security and efficiency of smart contract development. The pattern consists of three steps: checking inputs and conditions, updating the state of the contract if the inputs and conditions are valid, and finally, interacting with external contracts or the blockchain if required.
In the first step, the input data and conditions are checked to ensure that they meet the requirements for the intended action. This includes checking for things like valid addresses, sufficient balances, and correct signatures, among other conditions. This step helps to prevent errors and security vulnerabilities by ensuring that only valid inputs are processed.
In the second step, the state of the contract is updated based on the inputs and conditions. This includes updating balances and performing other state-changing actions. This step is critical to ensuring the proper functioning of the smart contract and maintaining the integrity of the blockchain.
In the final step, the contract interacts with external contracts or the blockchain to complete the desired action. This includes sending tokens, invoking functions on other contracts, or writing data to the blockchain. This step is necessary for the smart contract to interact with other contracts or to make changes to the state of the blockchain.
In this case, we will have the
withdraw
function looking like this;function withdraw() public payable { uint bal = balances[msg.sender]; require(bal >= 0); balance[msg.sender] = 0; (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Transaction failed") }
Using the CEI pattern can help developers write more secure and efficient smart contracts by ensuring that inputs and conditions are validated before any state changes are made and that interactions with external contracts or the blockchain are performed only after the contract state has been updated correctly. This pattern has become a best practice for writing Solidity smart contracts and is widely used by developers to ensure the safety and reliability of their code.
Using OpenZeppelin ReentrancyGuard contract:
You can visit the OpenZeppelin ReentrancyGuard to understand the contract better. You inherit the
ReentrancyGuard
contract into your smart contract and make use of thenonReentrant
modifier on your function. Here is an example;// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.0; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract Store is ReentrancyGuard { mapping(address => uint) public balances; function deposit() public payable { balances[msg.sender] += msg.value; } function withdraw() public payable nonReentrant { uint bal = balances[msg.sender]; require(bal >= 0); (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Transaction failed"); balances[msg.sender] = 0; } }
The
nonReentrant
modifier is added to ensure that the function can only be called once and cannot be re-entered by an attacker to drain the contract's funds. Using theReentrancyGuard
contract and thenonReentrant
modifier, one can add an extra layer of security to one’s Solidity smart contracts and protect them from reentrancy attacks.Using bool to lock the function until it is done running:
This is partially similar to the OpenZeppelin ReentrancyGuard contract discussed above. The concept is to prevent reentrancy attacks by ensuring that a function cannot be called again until it has completed its previous execution. If the attacker tries to run the function multiple times at once, it will not work. Here is an example below;
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.0; contract Store { bool internal locked; modifier nonReentrant () { require(!locked, "Not Permitted"); locked = true; _; locked = false; //this executes after the function is done } mapping(address => uint) public balances; function deposit() public payable { balances[msg.sender] += msg.value; } function withdraw() public payable nonReentrant { uint bal = balances[msg.sender]; require(bal >= 0); (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Transaction failed"); balances[msg.sender] = 0; } }
The
nonReentrant
modifier works by setting a boolean flag called locked to true before the function is executed. If the flag is already set to true, the function will revert and the user will not be able to execute it again. Once the function is complete, the flag is set back to false, allowing the function to be called again.
Conclusion
The reentrancy attack poses a significant threat to the security and integrity of smart contracts, particularly in the DeFi ecosystem. By exploiting the fallback function triggered during external contract calls, attackers can manipulate contract states, drain funds, and cause unintended behaviors.
Preventing reentrancy attacks requires diligent coding practices and adherence to security best practices. Implementing the "Checks-Effects-Interactions" pattern, employing reentrancy guard mechanisms, and conducting thorough testing and security audits are crucial steps to mitigate the risks.
As the popularity of decentralized applications and DeFi systems continues to grow, it becomes imperative for smart contract developers to be aware of the reentrancy attack and take proactive measures to fortify their contracts. By prioritizing security and employing robust defense strategies, we can build a safer and more trustworthy decentralized financial landscape.