The Ultimate Guide To Reentrancy

Reentrancy has a long history in Ethereum and is the vulnerability class responsible for The DAO Hack, one of the biggest attacks on the early Ethereum network in 2016. Since then, many standards have been introduced to mitigate this class of vulnerability, such as limiting gas forwarded by an external guard, reentrancy guards, and following Checks-Effects-Interactions (CEI) patterns.

Many now even question reentrancy’s relevance as an important vulnerability, given widespread knowledge of the attack vector and its patterns. However, taking a look at recent hacks and events tell a different story.

It’s been some time since Paweł Kuryłowicz wrote his article on whether reentrancy attacks are still a problem in Solidity. In 2021, Pawel asked:

> Okay, but is this reentrancy attack a significant problem?

And the answer was:

> Yes, it is a significant problem.

All this time later, are reentrancy attacks still a significant problem?

The answer is yes, and will likely continue to be yes for the foreseeable future.

How has reentrancy affected the ecosystem?

Since Pawel posted his article, hundreds of millions of dollars have been lost to reentrancy attacks, most notably in Fei’s Rari Fuse Pools incident, which had a reported loss of over $80 million dollars. Even though reentrancy is the most commonly referenced smart contract bug and is many security researchers’ first introduction to smart contract security, there are many projects still falling prey to attacks which at their core are a result of reentrancy. You can take a look at affected projects in pcaversaccio’s repository A Historical Collection of Reentrancy Attacks.

What is reentrancy?

Reentrancy is a state synchronization problem. When an external call is made to another smart contract, execution flow control is transferred. The calling contract has to make sure all globally shared state is fully synchronized before transferring control. Since the EVM is a single threaded machine, if a function does not fully synchronize state before transferring execution control, the function can be reentered with the same state as if it were being called for the first time. This can cause the function to execute actions repeatedly which were only intended to be executed once.

Fig 1: High level example of execution flow during a reentrancy attack

If we make one simple modification to the WETH contract, which wraps the native asset ether into an ERC20-compatible token, we can get a better understanding of an anti-pattern which can lead to reentrancy. The deposit function receives ether and increases the users’ balance stored in the balanceOf mapping.

mapping (address => uint) public balanceOf;

function deposit() public payable {
     balanceOf[msg.sender] += msg.value;
     Deposit(msg.sender, msg.value);
}

function withdraw(uint amount) public {
     require(balanceOf[msg.sender] >= amount);
     msg.sender.call{value: amount}("");
     balanceOf[msg.sender] -= amount;
     Withdrawal(msg.sender, amount);
}

When a user would like to convert their WETH back to ether, they call withdraw. When the withdraw function uses a low-level call to transfer ether to the user, execution flow is transferred to the receiver. In this example, the external call is being made before the balance is updated. If the caller is an EOA, the transfer completes successfully and execution continues within the withdraw function. However, if the caller were a smart contract, the default payable function is invoked which can be controlled to do anything we like.

During our execution of the default payable function, the WETH contract does not know it has sent the ether yet since the balanceOf mapping has not yet been modified! If we call back into the withdraw function, the require statement that checks we have enough balance of WETH to withdraw ether will pass. We’ve just hacked the WETH contract and gained infinite ether!

* Once all reentrant calls resolve, the balanceOf mapping is still decreased according to the number of times the function was called. In Solidity versions >= 0.8.0 this will cause the entire function to revert because of underflow/overflow checks which occur by default. However, any Solidity version below this will underflow the balance and the attacker will additionally end up with a very large balance.

What can you do to prevent reentrancy?

The first cases of reentrancy occurred on transfers of ether, since code execution is transferred to the receiver fallback function during a transfer of the native asset. The functions send and transfer were introduced to Address types to transfer ether, but limit the amount of gas forwarded to the receiver to restrict the logic which can be executed. This mitigated potential gas griefing risks, as well as prevented reentrancy since the inner call would run out of gas before being able to perform the necessary logic. There are downfalls to this solution, however. The use of transfer or send will break composability with smart contracts which may have some necessary logic occur in the fallback function, such as proxies, which delegate their logic to an implementation contract.

Use of send and transfer has been recommended against, due to potential changes to the gas costs of opcodes which may break existing contracts that rely on the limited amount of gas passed during those calls. ConsenSys details this issue more in their article Stop Using Solidity’s transfer() Now, but because gas costs are subject to change and there are more effective ways to mitigate reentrancy risks, send and transfer should not be used if following best practices.

The most recommended and simplest way to prevent reentrancy is to implement a checks-effects-interactions (CEI) pattern. Those functions which execute external calls should ensure that all external interactions occur after any checks or state changes. This is also commonly known as the tail call pattern in traditional concurrent programming. If we were to fix the previous example in the withdraw function of WETH, we would first have the require statement which checks the user has enough WETH balance (check), make our changes to storage which updates the users balance (effect), and finally make the external call to the user to transfer the funds (interactions).

function withdraw(uint amount) public {
     require(balanceOf[msg.sender] >= amount); // checks
     balanceOf[msg.sender] -= amount; // effects
     msg.sender.call{value: amount}(""); // interactions
     Withdrawal(msg.sender, amount);
}

Finally, if there are unknown risks which may be introduced through permissionless operation of a protocol, a reentrancyGuard may be used as a way to ensure there is no way to call the function more than once within the same call frame. OpenZeppelin provides a library for implementing ReentrancyGuards. However, the extra gas cost of performing a SLOAD and SSTORE to check if the function has already been called will increase gas costs and may not be necessary if following recommended patterns. Additionally, this type of reentrancy guard will not protect against cross-contract reentrancy.

EIP-1153 aims to reduce this cost by introducing new opcodes for data which is discarded after every transaction

What can trigger reentrancy?

Any external call can lead to reentrancy if the proper CEI patterns are not being followed. Slither is an open source static analysis framework which can help auditors and bug hunters find potential entry points for reentrancy vulnerabilities. However, the following standards are a few examples of ways execution flow may be transferred to an arbitrary contract:

  • Low level calls (.call())
  • transfer of ERC223 tokens
  • transferAndCall of ERC667 tokens
  • transfer of ERC777 tokens
  • safe* transfer functions of ERC1155 tokens
  • *AndCall functions of ERC1363 tokens
  • safe* functions such as safeTransfer or safeMint of ERC721 tokens
  • transfer of certain ERC20 tokens which may have implemented a custom callback function for receivers

What are the different types of reentrancy?

Single-function Reentrancy

This is the simplest type of reentrancy which led to The DAO Hack of $60 million dollars and the hard fork of the Ethereum network, resulting in the creation of separate blockchains, the unaltered “Ethereum Classic”, and the altered history Ethereum network we know today.

Single-function reentrancy occurs when a contract makes an external call before finalizing state changes, and the same function is reentered within the external call.

Cross-function Reentrancy

An attacker may also be able to do a similar attack using two different functions that share the same state. If the first function makes an external call before the shared data is updated, an attacker may enter the second function with the unchanged state.

OpenZeppelin’s reentrancy guard may prevent this issue if both functions have a nonReentrant guard, since they share the same storage value as the value which is checked to tell if the function has already been called. This also prevents functions with the nonReentrant modifier from being called within the same call frame.

Cross-contract Reentrancy

Reentrancy is not limited to calls to functions within the same contract. Multiple contracts which share the same state can also be susceptible to reentrancy. Again, the CEI pattern would prevent any risks of reentrancy. However, if the shared state is not updated before the external call, reentrancy could cause a critical vulnerability. You can read more about cross-contract reentrancy in this example by Phuwanai Thummavet.

Read-Only Reentrancy

Typically, auditors and bug hunters are only concerned with entry points that modify state when looking for reentrancy. However, read-only reentrancy can occur when a protocol relies on reading the state of another. Most notably, Curves’ get_virtual_price was susceptible to this type of attack by reentering the view function get_virtual_price in the middle of removing liquidity. In many cases, this will affect protocols which rely on a pricing mechanism of another, so projects should be very careful when integrating price oracles of exchanges or other liquidity management protocols. Read more about read only reentrancy in the wild in Curve LP Oracle Manipulation: Post Mortem by Chain Security. Additionally, you can find examples of read only reentrancy in SunWeb3Sec’s DeFiVulnLabs common smart contract vulnerabilities repository.

Cross-Chain Reentrancy

Cross-chain reentrancy is the newest type of reentrancy attack which only recently started to become a concern with the rise of cross-chain messaging protocols. There is no precedent for cross-chain reentrancy attacks in the wild. However, with the rise in cross-chain interoperability and unified vision of a multi-chain future, this paradigm must be understood and reviewed by any protocols which bridge assets between chains, or utilize cross-chain messaging. An example created specifically to demonstrate cross-chain reentrancy can be seen here.

Future of reentrancy?

The introduction of new transient storage opcodes TSTORE and TLOAD in EIP-1153 presents an opportunity to improve reentrancy protections in smart contracts. These opcodes allow for the storage of data in a temporary location that is reset after a contract function completes, making it impossible for an attacker to reenter a function. Typically, reentrant guards were achieved using storage. That being said, SSTORE and SLOAD opcodes have significant gas costs. OpenZeppelin’s reentrancy guards may likely change to using more gas-efficient transient storage opcodes.

With the addition of these new opcodes, there are also initiatives to disable reentrancy by default at the compiler level. This would provide an additional layer of protection against reentrancy attacks and help ensure that developers are aware of the risks associated with reentrant code. The Vyper and Solidity programming languages are both considering implementing this feature, which would make it easier for developers to write secure contracts and may lead to a paradigm shift for developers when considering external calls within their smart contracts.

Until then, reentrancy attacks are still a serious concern in the world of smart contracts.

Therefore, it is essential for developers to remain vigilant in their coding practices and adopt best security practices to minimize the risk of reentrancy attacks. Additionally, auditors and security researchers play a crucial role in identifying vulnerabilities and providing feedback to developers. By working together, the blockchain community can continue to improve the security of smart contracts and prevent reentrancy attacks from causing further harm through bug bounties and audits.