Unboxing re-entrancy attack

Unboxing re-entrancy attack

The most known vulnerability in Smart Contracts. The one that made the Ethereum do a hard fork and split between Ethereum and Ethereum Classic. When it was first unveiled, it was during a multimillion-dollar heist which led to the split mentioned above in the chain.

Five years have passed since its first discovery or, better say, first spotted occurrence in the wild. Have we learned anything in the past years, or is it still an issue? Let's unbox the attack and see for ourselves.

What is the re-entrancy attack?

Reentrancy occurs when external contracts calls are allowed to make new calls to the calling contract before the initial execution is completed. Seems a bit convoluted? It's not. Let's see an example.

contract Funds {
    mapping(address => uint) balances;

    function withdraw(uint amount) public {
        payable(msg.sender).call{value: amount}("");
		   balances[msg.sender] = balances[msg.sender] - amount;
    }
}

At first glance, for not experienced Solidity devs, it looks fine.

  1. We call withdraw with the specified amount we want to withdraw
  2. Our funds are sent to us
  3. Balance is adjusted

But the issue here is the following. The function which has a call to an external address (given by the function param or contract can call our function) can cause our contract state change in the middle of the execution.

Any interaction from a contract (A) with another contract (B) and any transfer of Ether hands over control to that contract (B). This makes it possible for B to call back into A before this interaction is completed. It is possible due to the fallback function of a contract.

The fallback function is executed on a call to the contract if none of the other functions match the given function signature.

So what happens exactly is the following.

  1. Malicious contract uses the withdraw() function to retrieve its balance
  2. The Funds contract executes payable(msg.sender).call{value: amount}(""); and sends the ether to the Malicious contract, before updating the balance.
  3. The Malicious contract have a fallback() function which is executed upon receiving ether. It calls back the Funds contract and withdraw() function
  4. The second execution of the function sends ether to the Malicious contract. The original execution hasn't updated the code yet. Thus, we can call the Malicious contract repeatedly, as many times as we want to deplete the vulnerable contract from its funds.

Now, this sounds scary. With a simple trick like that, we can steal a lot of funds, if not all, from a vulnerable contract only because they do calls to external addresses before updating the state of the contract. Of course, the above example is very simplified, and it doesn't only affect ether transfers. ERC721 tokens and ERC777 tokens have a way to do a callback to the msg.sender, informing about the transfer. This opens the gate to re-entrancy attack as traditional ERC20 tokens do not make a calls to the msg.sender, only changes the contract state.

How can we defend against re-entrancy?

The solution is quite simple. The most successful key to re-entrancy attacks is ensuring that state changes are made last. An official recognised pattern in Solidity is the Checks-Effects-Interactions pattern.

As the name suggests, the first contract should check all the inputs and states before doing anything else, such as checking the msg.sender's balance is correct and is authorized. This will ensure function will be working on a proper state of the contract.

Next, the function should perform all of the state changes that should be made—for example, reducing the balance of the msg.sender. Interaction with other contracts should be the very last step in any function.

The last step is to perform any external calls. Due to the measures taken before, any external call won't be able to re-enter the function in any malicious way as all necessary state changes are done before.

Another approach to guard the contract before re-entrancy attack is to use mutex states. Not all contracts can benefit from the checks-effects-interaction pattern. As my co-worker explains in his take on the issue

"There are certain edge-cases, however, where the Checks-Effects-Interactions pattern cannot be applied due to the needs of the contract. A prime example of this is the Uniswap V2 pair contract which relies on balanceOf measurements beyond the conclusion of an external call."

In cases like this, the contract can rely on mutex checks. Such check wrapped in the modifier is provided by the ReentrancyGuard.sol from OpenZeppelin. It includes nonReentrant modifier which can be inherited and used across the contract. This modifier would still make it possible to have an external call happen before state changes happen, but it ensures that function cannot be re-entered again before state changes are made.

This is helpful, but let's remember, in multi-contract architecture, which is often found in DeFi, a simple nonReentrant modifier won't solve all the issues. Re-entrancy attack can still happen, and more thorough checks from auditors should be performed to eliminate any illegal entry points. For example, if an external contract relies on the state from the original contract (getter function), then the external contract can be susceptible to an re-entrancy exploit.

Is this attack vector still a reality?

After 5 years of discovery of this exploit, I saw many times during an audit, where non of the above recommendations were applied. After an audit, they were, of course, but it's frightening such simple precautionary measures aren't there from the start.

We don't hear about reentrancy attacks very often but they resurface from time to time. Uniswap and ERC777 come to mind or Origin Protocol exploit. All happened last year, but the fact the attack vector is well-known, it is still surprising such attacks are still possible.

Each project should protect itself from this attack as it isn't hard to do so. Having checks-effects-interactions pattern applied, even for internal functions, doesn't increase the gas costs of the operations. With the ever-growing composability of DeFi, we never know when such an attack can become available to exploitation.


Thanks for reading, and if you like my writing, you can subscribe to my blog to receive the daily newsletter as I'm currently in the middle of 100 days of blogging challenge. Subscription box below 👇

If the newsletter is not your thing, check out my Twitter @adrianhetman, where I post and share exciting news from the Blockchain world and security.

See you tomorrow!