Reentrancy After Istanbul

How to protect your contracts against reentrancy after Ethereum’s Istanbul hard fork.


The upcoming Istanbul hard fork, scheduled for early December, includes among other things EIP1884: “Repricing for trie-size-dependent opcodes”. The key word is “repricing”, and it means that some instructions will now cost more gas to execute. The reason why this has been talked about so much lately is that existing code on-chain that runs with a limited amount of gas may now go over that limit and result in “out of gas” errors.

A particular case of “code that runs with a limited amount of gas” is the fallback function in any Solidity contract, since it’s the code that will run during a transfer of Ether triggered by Solidity’s transfer function, or Solidity and Vyper’s send functions. Both transfer and send only allow the receiver of Ether a small gas stipend of 2300 (which is practically nothing). Fallback functions that were close to this limit may stop working once Istanbul arrives, as well as any contracts that were calling into these functions with limited gas. (See ChainSecurity’s list of affected contracts, and our own article specific to OpenZeppelin upgradeable contracts.)

For security reasons, transfer and send had until now been the recommended way to transfer Ether. The amount of gas they allow is not enough to perform reentrancy attacks, so the argument was that it would protect contracts against them. And that it has… but the Ethereum developer community is now facing the reality that opcode pricing cannot be considered stable, and that if we wish to build future-proof systems we should look to other ways of being secure. Namely, we should stop using transfer in favor of other methods for sending Ether, and rely on other security techniques for preventing reentrancy.

This article explains reentrancy, the techniques currently available to secure a contract against it, and how you can use OpenZeppelin Contracts to readily implement them in your project. Particularly noteworthy is one technique that we haven’t seen mentioned enough: the pull payments approach.

What is Reentrancy?

A contract during its normal execution may perform calls to other contracts, by doing function calls or simply transferring Ether. These contracts can themselves call other contracts. In particular, they can call back to the contract that called them, or any other in the call stack. In that case, we say that the contract is re-entered, and this situation is known as reentrancy.

Reentrancy by itself is not an issue. The issue arises when a contract is re-entered in an “inconsistent” state. State is considered consistent when the contract-specific invariants hold true. In the case of ERC20, for example, the main invariant is that the sum of all contract balances does not exceed the known total supply.

Generally, functions assume that when they begin running they are observing the contract in a consistent state, and they also promise to leave it consistent once they have finished running. In the middle of their execution the invariants may be violated, but this is fine as long as no one can observe the inconsistent state. The issue is that through reentrancy this becomes possible. It is not only necessary for the invariants to hold when the function finishes, but also at every potential reentrancy point.

Our code is vulnerable to reentrancy attacks when we call untrusted contracts or transfer funds to untrusted accounts. These accounts can be programmed specifically to abuse invariant violations during a reentrant call.

If you would like to play around with this concept to understand it fully, check out the Reentrancy level in Ethernaut, our Solidity wargame. (What follows spoils the solution to the challenge, so you might want to skip to the next section.) The invariant here is that the amount of funds in the contract is equal to the sum of all entries in the balances mapping. During the execution of the call in the third line, the invariant is broken because _amount funds have been transferred out but balances hasn’t been updated yet. The very same call allows reentrancy, because msg.sender can be a contract. If an attacker triggered reentrancy at this point, they would be able to profit off the broken invariant.

function withdraw(uint _amount) public {
  if (amount <= balances[msg.sender]) {
    msg.sender.call.value(_amount)();
    balances[msg.sender] -= _amount;
  }
}

We will now see several ways to defend against these attacks.

Checks-Effects-Interactions

The first technique that we should mention is known as the Checks-Effects-Interactions pattern. It describes a way of organizing the statements in a function such that a contract’s state is left in a consistent state before calling out to other contracts. This is done by classifying every statement as either a check, an effect (state change), or an interaction, and ensuring that they are strictly in this order. By placing effects before interactions, we make sure that all state changes are done before any potential reentrancy point, leaving the state consistent.

This pattern has been discussed quite a lot already, and you should read about it in Solidity’s documentation and ConsenSys’s Best Practices.

We should be dissatisfied with this approach, though, because it is vulnerable to human error: the programmer must apply it correctly, and the reviewers must spot any errors. Is it possible to relieve the poor humans from this responsibility?

Note: As a security conscious developer you should always distrust humans. Nevertheless, please be kind to them.

Reentrancy Guard

If at any point of execution we are unsure whether our contract’s invariants hold or not, we should avoid calling other (untrusted) contracts, because they could re-enter. If we have no choice but to do so, we can try to prevent reentrancy by using a reentrancy guard.

A reentrancy guard is a piece of code that causes execution to fail when reentrancy is detected. There is an implementation of this pattern in OpenZeppelin Contracts called ReentrancyGuard, which provides the nonReentrant modifier. Applying this modifier to a function will render it “non-reentrant”, and attempts to re-enter this function will be rejected by reverting the call.

What happens when our contract has multiple functions? Since the modifier is applied per function, if we want to completely prevent reentrancy we have to apply it to all of them. Otherwise, it would still be possible to re-enter into another function and use it in a reentrancy attack, if it is sensitive to broken invariants.

If we decide to make every function nonReentrant, though, we should keep Solidity’s public variables in mind. A contract variable marked public will generate a getter function to read its value, and there’s no way to apply a modifier to that function. In most cases this will not cause reentrancy problems, but it’s still worth worrying about, because it can result in other contracts observing inconsistent state due to broken invariants, which they will assume to hold.

With all its caveats, reentrancy guards can be valuable in some scenarios. However, eliminating reentrancy entirely also has its downsides: reentrancy can be safe in some cases, and as Ethereum smart contracts become more complex, composable, and connected, we might see legitimate uses of it in the wild.

Pull Payments

If we transferred Ether to a contract but did not run its code, reentrancy simply wouldn’t be possible. This bypassing of the receiver’s code is possible in the EVM by using selfdestruct. However, contracts that receive Ether need to process it in some way, and most are not programmed to deal with funds received via selfdestruct, which can result in loss of funds.

The alternative is the pull payment pattern. The idea is that instead of “pushing” funds to a receiver, they have to be “pulled” out of a contract. OpenZeppelin Contracts implements this pattern in the PullPayment contract. Inheriting this contract makes available an internal function, _asyncTransfer, that is analogous to transfer. Instead of sending the funds to the receiver, however, it will transfer them to an escrow contract. Additionally, PullPayment provides a public function for receivers to withdraw (pull) their payments: withdrawPayments.

Since version 3.0 of OpenZeppelin Contracts, withdrawPayments forwards all available gas to the receiver during the actual Ether transfer. Note that reentrancy at this point is possible, but it will be safe, because PullPayment doesn’t invalidate any of your contract’s invariants.

(At the time this post was published, prior to the 3.0 release, withdrawPayments would only forward the stipend. An alternative withdraw function, withdrawPaymentsWithGas, was introduced in 2.4 as a backwards-compatible fix for Istanbul’s opcode repricing. As mentioned in the previous paragraph this has now become the default withdrawal behavior.)

It’s worth mentioning that the withdrawal function can be called by anyone, and not just the receiver. This means that the receiver doesn’t need to be aware that it is the target of a pull payment, which is particularly important when it’s an existing smart contract that is unable to pull for itself.

Summary

Given unstable opcode pricing, we can no longer rely on transfer, and thus reentrancy is becoming unavoidable in post-Istanbul world. Attackers can use it against a contract that calls out to untrusted accounts when state invariants are broken. It’s necessary to program our contracts to be safe against reentrancy by organizing code according to the checks-effects-interactions pattern, or by using tools like reentrancy guards or pull payments.