📘Understanding Reentrancy

Reentrancy, a term originating from computer science, refers to a situation where a piece of code can be interrupted and run again before its previous execution has completed. In the context of smart contracts and blockchain, a reentrancy attack is a kind of malicious action which allows an attacker to repeatedly call a function of a smart contract before the first function call has ended, potentially draining funds or causing other harm. To fully grasp the complexities of reentrancy, let's unpack this concept step by step.

Smart Contracts and Function Calls

A smart contract is a self-executing contract with the terms of the agreement directly written into lines of code. Ethereum was the first blockchain to introduce smart contracts. These are autonomous scripts deployed on the Ethereum network that automatically execute transactions if certain conditions are met.

Function calls and external interactions

When interacting with smart contracts, external accounts (like a user's wallet) or other smart contracts can trigger functions within the contract.

For example

Let's explore a scenario where we have two smart contracts: ContractA and ContractB. In this scenario, ContractA wants to trigger a function within ContractB.

Here is what our ContractB might look like:

pragma solidity ^0.8.7;

// This is ContractB
contract ContractB {
    uint public data;

    function setData(uint _data) public {
        data = _data;
    }
}

ContractB is straightforward, and it just contains a public variable data and a function setData which updates the data variable.

Next, let's take a look at ContractA, which will interact with ContractB.

pragma solidity ^0.8.7;

// This is ContractA
contract ContractA {
    address contractBAddress;

    constructor(address _contractBAddress) {
        contractBAddress = _contractBAddress;
    }

    function callSetDataOnContractB(uint _data) public {
        // Creating an instance of ContractB
        ContractB contractB = ContractB(contractBAddress);
        // Now we can call setData on ContractB
        contractB.setData(_data);
    }
}

In ContractA, we store the address of ContractB in a variable contractBAddress. In the callSetDataOnContractB function, we create an instance of ContractB using the stored address. Now we can call the setData function of ContractB directly from ContractA.

The key to making this interaction is that ContractA must have the address of ContractB. This address is how it knows where to send the function call in the Ethereum network. Moreover, it needs to know the interface of ContractB (i.e., the function signatures of ContractB), so it knows what functions it can call and what parameters they require. This is why we can see ContractB's setData function being called within ContractA with the correct number and type of parameters.

This example shows a simple interaction between two contracts, but it demonstrates how one contract can trigger functions within another contract. A function is a piece of code designed to perform a specific task. For instance, a smart contract managing a digital wallet might have functions to deposit or withdraw funds.

One key feature of smart contracts is that they can interact with other smart contracts by calling their functions. Such an interaction is an external call. A function in smart contract A can call a function in smart contract B, causing the code in B to execute.

The Crux of Reentrancy Attacks

Reentrancy attacks exploit the potential vulnerability in the order of operations in smart contracts, particularly when a function calls an external contract and then continues to execute.

In a reentrancy attack, a malicious contract manipulates the control flow of the contract by causing it to execute certain parts of its code multiple times, changing its state in unexpected ways. This occurs when a function of the vulnerable contract, in the midst of its execution, calls another function (possibly external), which in return calls the original function, creating a loop. Example

Let's imagine there is a bank-like contract that allows users to deposit and withdraw Ether. This contract might look something like this:

pragma solidity ^0.8.7;

contract VulnerableBank {
    mapping (address => uint) private balances;

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

    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= _amount;
    }

    function getBalance() public view returns (uint) {
        return balances[msg.sender];
    }
}

The VulnerableBank contract has three main functions:

  1. deposit(): Allows a user to deposit ether. The ether sent with the transaction (msg.value) is added to the sender's (msg.sender) balance.

  2. withdraw(uint _amount): Allows a user to withdraw ether. It first checks whether the user has sufficient balance. Then, it sends the specified _amount of ether to the sender. If the ether transfer is successful, it subtracts the _amount from the sender's balance.

  3. getBalance(): Allows a user to check their balance.

Now, let's say there is a malicious actor who wants to exploit the withdraw function using a reentrancy attack. They could create a malicious contract that looks like this:

pragma solidity ^0.8.7;

contract MaliciousContract {
    VulnerableBank public vulnerableBank;
    
    constructor(address _vulnerableBankAddress) {
        vulnerableBank = VulnerableBank(_vulnerableBankAddress);
    }
    
    // Fallback function which is called whenever the contract receives ether
    fallback() external payable {
        if (address(vulnerableBank).balance >= 1 ether) {
            vulnerableBank.withdraw(1 ether);
        }
    }
    
    // Initial function to start the attack
    function attack() external payable {
        require(msg.value >= 1 ether);
        vulnerableBank.deposit{value: 1 ether}();
        vulnerableBank.withdraw(1 ether);
    }
}
  1. VulnerableBank public vulnerableBank;: This line declares a state variable vulnerableBank of the type VulnerableBank, which is the contract to be attacked. The public keyword allows this variable to be read from outside the contract.

  2. constructor(address _vulnerableBankAddress) {: This line defines the constructor function for the MaliciousContract. The constructor function is called when the contract is deployed. This constructor takes one parameter: the address of the VulnerableBank contract.

  3. vulnerableBank = VulnerableBank(_vulnerableBankAddress);: This line casts the address _vulnerableBankAddress to type VulnerableBank and assigns it to vulnerableBank. This allows MaliciousContract to interact with the VulnerableBank contract using this variable.

  4. fallback() external payable {: This line defines the fallback function of the contract. It is marked as external, meaning it can be called from outside the contract, and

  5. function attack() external payable {: This line defines a new function named attack. It is marked as external, so it can be called from outside the contract, and payable, so it can receive Ether.

  6. require(msg.value >= 1 ether);: This line checks if the incoming transaction (the one calling attack) includes at least 1 ether. If it doesn't, the function will revert and stop executing.

  7. vulnerableBank.deposit{value: 1 ether}();: This line deposits 1 ether into the VulnerableBank contract by calling its deposit function. The value: 1 ether syntax specifies that 1 ether should be sent with this function call.

  8. vulnerableBank.withdraw(1 ether);: This line withdraws 1 ether from the VulnerableBank contract. If the VulnerableBank contract is vulnerable to reentrancy attacks, this will trigger the fallback function in the MaliciousContract, potentially starting a cycle of reentrant calls that could drain the VulnerableBank contract's balance.

Let's dissect the malicious contract and the reentrancy attack step-by-step:

  1. Initialization: The attacker deploys the MaliciousContract and provides the address of the VulnerableBank contract during the deployment (passed as _vulnerableBankAddress to the constructor).

  2. Start of Attack: The attacker triggers the attack by calling the attack function and sends along 1 Ether. The function does two things: It first deposits 1 Ether to the VulnerableBank contract (via vulnerableBank.deposit{value: 1 ether}();) thereby increasing the attacker's balance in the VulnerableBank. Next, it attempts to withdraw the same 1 Ether (via vulnerableBank.withdraw(1 ether);).

  3. First Withdrawal Attempt: When the withdraw function is called, the VulnerableBank contract checks whether the attacker has enough balance. Since we've just deposited 1 Ether, this check passes. Next, the contract attempts to send the 1 Ether back to the attacker's contract.

  4. Fallback Function: As soon as the MaliciousContract receives the Ether, its fallback function is triggered. If there's more than 1 Ether left in the VulnerableBank contract, it calls the withdraw function again.

  5. Second Withdrawal Attempt: Now, back in the VulnerableBank contract, the withdraw function starts its execution again. It checks whether the attacker has enough balance. As the balance has not yet been updated from the previous withdrawal (because the execution hasn't completed yet), this check still sees the original 1 Ether balance and passes. The contract sends another Ether to the MaliciousContract.

  6. Loop Continues: Steps 4 and 5 repeat, causing the fallback function to repeatedly call the withdraw function before the VulnerableBank contract can update its state. This is the loop that drains Ether from the VulnerableBank contract. Each loop withdraws 1 Ether from the VulnerableBank and adds it to the MaliciousContract.

  7. End of Attack: The attack continues as long as there's at least 1 Ether left in the VulnerableBank contract. Once the balance drops below 1 Ether, the conditional check in the fallback function of MaliciousContract fails, breaking the loop. By the end of the attack, the VulnerableBank is drained of its Ether, and the balance recorded in the VulnerableBank for the attacker is much higher than what's actually left in the contract.

This example illustrates how reentrancy attacks take advantage of the order of operations in a contract, manipulating the contract to perform actions repeatedly before it can update its state.

Why does this happen?

In Solidity (and many other programming languages), when a function calls another function, control is passed to the called function. This means that until the called function finishes executing and returns, the calling function is paused and waits for the completion of the called function.

This concept is also true when the function is not just a different function within the same contract, but a function from an entirely different contract. So when a contract function calls another contract's function, the control flow is passed to that other contract until the function finishes executing.

In the context of reentrancy attacks, this transfer of control flow becomes significant. If the called function is part of a malicious contract and it manages to call back into the original calling contract before it finishes execution, it can potentially exploit vulnerabilities and manipulate the state of the original contract.

For instance, if the original contract's function was supposed to decrease the balance of an account after it has transferred funds to that account, but the malicious contract calls back into the original contract before it finishes execution, the balance might not be decreased as intended. The malicious contract can continue this loop of calling the original contract's function to drain all the funds.

That's why it's crucial to manage the order of operations in smart contracts correctly, especially in the context of calls to other contracts, to avoid potential reentrancy attacks.

Last updated