Protect Your Solidity Smart Contracts From Reentrancy Attacks

Will Shahda
Apr 24, 2019 · 6 min read

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.

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.

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.

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.

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();
}

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.

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.

Get Best Software Deals Directly In Your Inbox

Coinmonks

Coinmonks is a non-profit Crypto educational publication.

Coinmonks

Coinmonks is a non-profit Crypto educational publication. Follow us on Twitter @coinmonks Our other project — https://coincodecap.com

Will Shahda

Written by

Solidity Engineer and Founder of APY.Finance.

Coinmonks

Coinmonks is a non-profit Crypto educational publication. Follow us on Twitter @coinmonks Our other project — https://coincodecap.com