Protect Your Solidity Smart Contracts From Reentrancy Attacks
--
One of the most devastating attacks you need to watch out for when developing smart contracts with Solidity are reentrancy attacks. They are devastating for two reasons: they can completely drain your smart contract of its ether, and they can sneak their way into your code if you’re not careful.
A reentrancy attack can occur when you create a function that makes an external call to another untrusted contract before it resolves any effects. If the attacker can control the untrusted contract, they can make a recursive call back to the original function, repeating interactions that would have otherwise not run after the effects were resolved.
This simplest example is when a contract does internal accounting with a balance variable and exposes a withdraw function. If the vulnerable contract transfers funds before it sets the balance to zero, the attacker can recursively call the withdraw function repeatedly and drain the whole contract.
Let’s look at an example:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
All an attacker needs to exploit this function is to get some amount of balance mapped to their smart contract address and create a fallback function that calls withdraw.
After msg.sender.call.value(amount)()
transfers the correct amount of funds, the attacker’s fallback function calls withdraw
again, transferring more funds before balances[msg.sender] = 0
can stop further transfers. This continues until there is either no ether remaining, or execution reaches the maximum stack size.
Typically a vulnerable function will make an external call using transfer
, send
, or call
. We will cover the differences between these functions in the section on preventing reentrancy attacks.
Types of reentrancy attacks
There are two main types of reentrancy attacks: single function and cross-function reentrancy.
Single function reentrancy attack
This type of attack is the simplest and easiest to prevent. It occurs when the vulnerable function is the same function the attacker is trying to recursively call.
Our previous code example is a single function reentrancy attack.
Cross-function reentrancy attack
These attacks are harder to detect. A cross-function reentrancy attack is possible when a vulnerable function shares state with another function that has a desirable effect for the attacker.
This is easiest to explain with an example:
function transfer(address to, uint amount) external {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
In this example, withdraw
calls the attacker’s fallback function same as with the single function reentrancy attack.
The difference is the fallback function makes a call to transfer
instead of recursively calling withdraw
. Because the balance has not been set to 0 before this call, the transfer
function can transfer a balance that has already been spent.
How bad can a reentrancy attack be?
Just ask someone who invested in The DAO back in 2016. The DAO hack was one of the highest profile reentrancy attacks in Ethereum’s history. An attacker managed to drain it of about 3.6 million ether.
The DAO had a vulnerable function meant to split off a child DAO. The attacker used this function to recursively transfer funds from the original DAO to the child DAO that they controlled.
The hack was so damaging the Ethereum Foundation resorted to a controversial hard fork that recovered investor funds. Most supported the hard fork, but part of the community thought it violated the core principles of cryptocurrency — namely immutability — and continued to use the old chain resulting in the creation of Ethereum Classic.
Read more about the DAO hack here: http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/
Prevent reentrancy attacks
There are a few best practices you should follow to protect your smart contracts from reentrancy attacks.
send, transfer, and call
Because most reentrancy attacks involve send
, transfer
, or call
functions — it is important to understand the difference between them.
send
and transfer
functions are considered safer because they are limited to 2,300 gas. The gas limit prevents the expensive external function calls back to the target contract. The one pitfall is when a contract sets a custom amount of gas for a send or transfer using msg.sender.call(ethAmount).gas(gasAmount)
.
The call
function is unfortunately much more vulnerable.
When an external function call is expected to perform complex operations, you typically want to use the call
function because it forwards all remaining gas. This opens the door for an attacker to make calls back to the original function in a single function reentrancy attack, or a different function from the original contract in a cross-function reentrancy attack.
Wherever possible, use send
or transfer
in place of call
to limit your security risk.
Mark untrusted functions
To protect against reentrancy attacks, it is important to identify when a function is untrusted. The Consensys best practices recommends that you name functions and variables to indicate if they are untrusted.
For example:
function untrustedWithdraw() public {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
It is important to remember that if a function calls another untrusted function it is also untrusted.
function untrustedSettleBalance() external {
untrustedWithdraw();
}
Checks-effects-interactions pattern
The most reliable method of protecting against reentrancy attacks is using the checks-effects-interactions pattern.
This pattern defines the order in which you should structure your functions.
First perform any checks, which are normally assert
and require
statements, at the beginning of the function.
If the checks pass, the function should then resolve all the effects to the state of the contract.
Only after all state changes are resolved should the function interact with other contracts. By calling external functions last, even if an attacker makes a recursive call to the original function they cannot abuse the state of the contract.
Let’s rewrite our vulnerable withdraw function using the checks-effects-interactions pattern.
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
require(msg.sender.call.value(amount)());
}
Because we zero out the balance — an effect — before making an external call, a recursive call made by an attacker will not be tricked into thinking there is still a remaining balance.
Mutex
In more complex situations such as protecting against cross-function reentrancy attacks it may be necessary to use a mutex.
A mutex places a lock on the contract state. Only the owner of the lock can modify the state.
Let’s look at a simple implementation of a mutex.
function transfer(address to, uint amount) external {
require(!lock);
lock = true; if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
} lock = false;
}function withdraw() external {
require(!lock);
lock = true; uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0; lock = false;
}
By using this lock, an attacker can no longer exploit the withdraw
function with a recursive call. Nor can an attacker exploit a call to transfer
for a cross-function reentrancy attack. All state modifications occur while lock
is true
, preventing any function checking the lock from being called out of order.
You must be careful implementing a mutex to make sure there is always a way for a lock to be released. If an attacker can get a lock on your contract and prevent its release your contract can be rendered inert.
OpenZeppelin has it’s own mutex implementation you can use called ReentrancyGuard
. This library provides a modifier you can apply to any function called nonReentrant
that guards the function with a mutex.
View the source code for the OpenZeppelin ReentrancyGuard
library here: https://github.com/OpenZeppelin/openzeppelin-solidity/blob/master/contracts/utils/ReentrancyGuard.sol
Keep in mind that a nonReentrant
function should be external. If another function calls the nonReentrant
function it is no longer protected.
The future of reentrancy attacks
There is always the risk of future updates introducing more opportunities for attacks. The Constantinople update was delayed because a flaw was discovered in EIP 1283 that introduced a new reentrancy attack using certain SSTORE
operations. Had this update been deployed to the mainnet, even send and transfer functions would have been vulnerable.
Attacks will get increasingly advanced and involve more complex interactions between functions and contracts to effect state. The best thing we can do to stay ahead is to keep interactions as simple as possible and employ best practices such as using transfer or send instead of call and using the checks-effects-interactions pattern to structure our functions.
Join Coinmonks Telegram Channel and Youtube Channel get daily Crypto News
Also, Read
- Copy Trading | Crypto Tax Software
- Grid Trading | Crypto Hardware Wallet
- Best Crypto Exchange | Best Crypto Exchange in India
- Best Crypto APIs for Developers
- Crypto Telegram Signals | Crypto Trading Bot
- Best Crypto Lending Platform
- An ultimate guide to Leveraged Token
- Best VPNs for Crypto Trading
- Crypto Trading Signals for Huobi | HitBTC Review
- TraderWagon Review | Kraken vs Gemini vs BitYard
- How to trade Futures on FTX Exchange
- OKEx vs KuCoin | Celsius Alternatives | How to Buy VeChain
- 3Commas vs. Pionex vs. Cryptohopper
- How to use Cornix Trading Bot
- Bitget Review | Gemini vs BlockFi cmd| OKEx Futures Trading
- 10 Best Places to Buy Crypto with Credit Card