Re-entrancy is a vulnerability in smart contracts that can result in unexpected behavior and loss of funds. This vulnerability enables an attacker to execute a function call repeatedly before the original call has completed.
Therefore, re-entrancy can change the contract’s state in unforeseen ways and trigger unintended operations. This can lead to significant security risks, such as loss of funds. There are different ways to prevent these kinds of attacks and mitigate their risks. One widespread method (and one we are going to explain in this post) is the use of libraries—specifically, a modifier imported from a library—that we can include in functions to prevent this type of attack.
In Solidity, a contract can be exploited or inadvertently trigger a re-entrancy attack by calling another contract’s function, which then calls back into the original contract’s function before it finishes executing. It is crucial to understand how to exploit this vulnerability to take the necessary security measures to prevent such attacks from occurring (refer to one of our previous articles for more information on becoming a Solidity developer).
Some code examples
To illustrate this vulnerability and learn how to prevent re-entrancy, let’s consider a simple example. Suppose we have a contract called Bank
, which stores a mapping of account balances, and allows users to withdraw their funds through a specific function.
contract Bank {
mapping (address => uint) public balances;
function deposit() public payable{
require(msg.value > 0, "funds needed to set balance");
balances[msg.sender] += msg.value;
}
function withdraw() public {
require(balances[msg.sender] > 0, "insufficient balance");
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success, "transfer failed");
balances[msg.sender] = 0;
}
}
The way in which the withdraw
function works is the following:
- First, the
require
statement checks if the user has funds associated to their account (meaning address) in order to withdraw them. - Then, the
msg.sender.call{value: amount}
statement sends the corresponding amount of ether to the user’s address. - Finally, the
balances[msg.sender] = 0
statement sets the user’s account balance back to0
.
However, this contract is susceptible to a re-entrancy attack. This is because the external call to the user’s address can trigger the execution of a function in another contract, which can then call back into the withdraw
function before it has finished executing.
Because the full function code of the original call hasn’t finished at this point, the user’s balance has not been updated yet. And so, the attacker can drain funds past the allowed amount, potentially depleting the entire contract balance.
For example, an attacker can create a malicious contract called Attack
, which can look like this:
contract BankAttack {
Bank bank;
function setBankContract(address bankContract) public {
bank = Bank(bankContract);
}
fallback () external payable {
if (address(bank).balance >= 1 ether){
bank.withdraw();
}
}
function attack() external payable {
require(msg.value == 1 ether);
bank.deposit{value: 1 ether}();
bank.withdraw();
}
}
The BankAttack
contract stores the address for the Bank
contract so it can call it, and it also has a fallback function. Fallback functions allow smart contracts to receive native tokens but also execute arbitrary code when this occurs. In this case, the fallback function repeatedly calls the Bank
contract’s withdraw
function, up until that contract’s balance is less than 1 ether.
The BankAttack
contract also has an attack
function. When called, 1
ether is first deposited so that the Bank
contract registers the attacking contract’s balance as greater than 0
, which will allow it to call withdraw
.
Then, the first call to the Bank
contract’s withdraw
function is successful, and it results in the withdrawal of that same ether back to the attacking contract. However, because ether is received, the fallback function in the BankAttack
contract is triggered, which calls the Bank
contract’s withdraw
function again, repeating the cycle. This will continue as long as funds remain, despite them not being associated to the attacking contract address. Thus, this creates a re-entrancy loop, and so funds can be repeatedly withdraw from the Bank
contract.
As long as the loop continues, the Bank
contract still has the attacking contract address’ balance as 1
ether, because the call cycle continues to occur after the initial deposit, but before the balance is adjusted back to 0
, which will only occur after the attack ends. Additionally, with the use of the setBankContract
function, the attack can be replicated on other vulnerable contracts.
To prevent this type of attack, the Bank
contract needs to implement effective security measures. We can take measures such as applying function modifiers. The nonReentrant
modifier can be created and applied to functions to prevent them from being called recursively before they have completed their execution (you can name the modifier whatever you like).
The nonReentrant function modifier
contract Bank {
mapping (address => uint) public balances;
bool private _entered;
modifier nonReentrant {
require(!_entered, "re-entrant call");
_entered = true;
_;
_entered = false;
}
function deposit() public payable{
require(msg.value > 0, "funds needed to set balance");
balances[msg.sender] += msg.value;
}
function withdraw() public nonReentrant {
require(balances[msg.sender] > 0, "insufficient balance");
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success, "transfer failed");
balances[msg.sender] = 0;
}
}
In this new version of the Bank
contract, we have added a nonReentrant
modifier to the withdraw
function to ensure that it can only be executed repeatedly only when it has finished in its entirety. This is achieved by introducing a boolean variable _entered
, added to track whether the function is currently being executed or not.
If the _entered
variable is already true
, it means that the function is currently being executed, and any attempts to call it again would result in a revert of the transaction. This prevents re-entrancy attacks.
On the other hand, if the _entered
variable is false
, it means that the function is not currently being executed, and it is safe to execute the function code. Before doing so, the modifier sets the _entered
variable to true
to prevent any further recursive calls. It then executes the function code using the _
placeholder, which represents the function code which the modifier wraps. Finally, it sets the _entered
variable back to false
, allowing the function to be called again after it has finished its execution.
This approach ensures that the function is executed without being re-entered, allowing a safe and reliable execution of its code. By using the nonReentrant
modifier, users can safely withdraw funds without the risk of losing them to a malicious attacker. You could re-utilize this modifier and apply it to any number of functions.
This modifier already exists, and is provided by OpenZeppelin as a library. In the next section we will look at how to install it.
How to install OpenZeppelin’s ReentracyGuard
Start by creating a new Solidity contract in your development environment. Let’s call it MyContract
.
In the MyContract
contract, include the ReentrancyGuard
library by adding it to the inheritance list. Your contract should look something like this:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MyContract is ReentrancyGuard {
// contract code goes here
}
Save the file and compile the contract. Then, to use the ReentrancyGuard
functionality in your contract, simply apply the nonReentrant
modifier to any functions that could be vulnerable to re-entrancy attacks. For example, if you once again have the withdraw
function, you can have its signature written like this:
function withdraw() public nonReentrant {
// function code goes here
}
That’s it! Your contract now includes the ReentrancyGuard
library from OpenZeppelin, and any functions with the nonReentrant
modifier are protected against re-entrancy attacks.
Checks, Effects, Interactions
In general, it’s a sensible idea to follow a pattern called Checks-Effects-Interactions. It essentially means: perform verifications first (requires, permissions), then update state variables for the contract (such as a balance) and finally interact with external elements like other contracts.
Let’s see how this would prevent the re-entrancy vulnerability without the use of libraries:
function withdraw() public {
// checks first
require(balances[msg.sender] > 0, "insufficient balance");
// effects second
balances[msg.sender] = 0;
// interactions last
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success, "transfer failed");
}
A malicious contract coded as previously shown would not succeed in its attack, even if the function can be re-entered. This is because the sender balance is updated before sending ether to it. When the ether is received by the attacking contract, it can still call withdraw
as a fallback action. However, the balance will update after each re-entrant call. This means that even through a chained withdrawal, it can never withdraw more ether than its corresponding balance allows.
The advantage to this approach is that by importing a library (such as OpenZeppelin’s) the contract size increases, since all of the inherited code is written into the contract’s bytecode at compilation time. As a result, a contract written without the library code will be smaller in size just by not having to use the nonReentrant
modifier.
The disadvantage to this approach is that its effectiveness depends on the correct application of the CEI pattern, and—just like any code in any language—this is always susceptible to bugs which stem from human error.
Conclusion
In conclusion, malicious actors can exploit the re-entrancy vulnerability, a serious attack vector in Solidity, in order to steal funds from smart contracts. It’s crucial to address this type of attack by taking precautions in your solidity functions. As we mentioned before, there are several ways of preventing the attack, but the use of audited libraries like OpenZeppelin is recommended.
Posted in Blockchain, Ethereum, Smart Contract, Solidity, Technologies