Re-Entrancy attack

Re-Entrancy attack

Understanding the Re-Entrancy attack and how you can secure your contracts from it.

The concept of a reentrancy attack was first identified in the context of Ethereum smart contracts in 2016 by a researcher named Nick Johnson. The attack is a type of vulnerability that can occur when a smart contract calls another contract and that second contract is able to call back into the original contract before any state changes have been made. This can lead to a situation where the second contract can repeatedly call back into the original contract and drain its balance of Ether.

Let us understand this with an example

Consider a simple ether airdrop contract that verifies an address’s eligibility and transfers ether.

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

contract AirDrop {

    mapping(address => uint256) public eligibleAmount;

    constructor(address[] memory _addresses, uint256[] memory _amounts) payable {
        uint256 length = _addresses.length;

        for(uint256 i = 0; i < length; i += 1) {
            eligibleAmount[_addresses[i]] = _amounts[i];
        }
    }

    function withdraw() external {
        address payable sender = payable(msg.sender);
        uint256 amount = eligibleAmount[sender];

        require(amount > 0, "Not Eligible");
        require(address(this).balance >= amount, "No Funds");

        (bool success, ) = sender.call{value: amount}("");

        require(success, "Ether Transfer Failed");

        eligibleAmount[sender] = 0;
    }
}

Taking a closer look at the withdraw function, the flow is

  1. Check the amount available for the address to withdraw is greater than 0.

  2. Check whether the required funds are available on the contract for transfer.

  3. Transfer the funds to the transaction sender.

  4. Set the amount available for the address to withdraw to 0.

If all your airdrop addresses are EOAs (Externally Owned Addresses) and not contract addresses, this is completely fine. But in most cases, there is a good probability that some of the addresses are contract addresses. This creates a huge flaw that enables people to use the receive function that gets triggered whenever a smart contract receives ether and allows them to drain all or at least more ether than eligible.

Let's try to implement a contract that can attack and drain funds from the airdrop contract.

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

import "./Airdrop.sol";

contract Attacker {

    function attack(AirDrop target) external {
        target.withdraw();
    }

    receive() external payable {
        AirDrop target = AirDrop(msg.sender);

        if (address(target).balance > 1 ether) {
            target.withdraw();
        }
    }
}

The logic flow is

  1. The transaction is initiated by calling the attack method on the Attacker contract,

  2. The Attacker contract calls the withdraw method of the airdrop contract.

  3. withdraw method checks the eligibility and transfers ether to the Attacker contract which triggers the receive hook.

  4. receive hook invokes the withdraw method again which triggers a loop until the AirDrop contract runs out of ether.

One of the most notable instances of a reentrancy attack occurred in 2016 when an attacker exploited a vulnerability in The DAO, a decentralized autonomous organization built on the Ethereum blockchain, to steal more than $50 million worth of Ether. The attack brought attention to the potential dangers of reentrancy attacks and led to a hard fork of the Ethereum blockchain to recover the stolen funds.

Since then, many other instances of reentrancy attacks have been identified and reported, highlighting the importance of careful contract design and testing to prevent such vulnerabilities. Some experts recommend implementing a mutex or a “re-entrancy guard” which can prevent the call from re-entering the same contract again before the state changes but no such features have been implemented as of January 2023.

How can you safeguard your contracts from Re-entrancy attacks?

One simple way to secure our contracts from re-entrancy attacks is by implementing the “checks-effects-interactions” pattern, where we update balances right after the validations and before invoking the ether transfer.

Solution using the “checks-effects-interactions” pattern:

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

contract AirDrop {

    mapping(address => uint256) public eligibleAmount;

    constructor(address[] memory _addresses, uint256[] memory _amounts) payable {
        uint256 length = _addresses.length;

        for(uint256 i = 0; i < length; i += 1) {
            eligibleAmount[_addresses[i]] = _amounts[i];
        }
    }

    function withdraw() external {
        address payable sender = payable(msg.sender);
        uint256 amount = eligibleAmount[sender];

        require(amount > 0, "Not Eligible");
        require(address(this).balance >= amount, "No Funds");

        /*Add Line*/ eligibleAmount[sender] = 0;

        (bool success, ) = sender.call{value: amount}("");

        require(success, "Ether Transfer Failed");

        /*Remove Line*/ // eligibleAmount[sender] = 0;
    }
}

This solution is quite simple but gets quite hard to track when the logic involved is a lot more complex including multiple external function calls. A much safer way to secure your contracts is to disable re-entrancy for your methods using modifiers

Solution using a Re-entrancy Guard Modifier:

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

contract AirDrop {

    mapping(address => uint256) public eligibleAmount;
    /*Add Line*/ bool lock;

    constructor(address[] memory _addresses, uint256[] memory _amounts) payable {
        uint256 length = _addresses.length;

        for(uint256 i = 0; i < length; i += 1) {
            eligibleAmount[_addresses[i]] = _amounts[i];
        }
    }

    /*Add Modifier*/
    modifier reentrancyGuard() {
        require(!lock, "Re-entrancy disabled");
        lock = true;
        _;
        lock = false;
    }

    /*Call Modifier*/
    function withdraw() external reentrancyGuard {
        address payable sender = payable(msg.sender);
        uint256 amount = eligibleAmount[sender];

        require(amount > 0, "Not Eligible");
        require(address(this).balance >= amount, "No Funds");

        (bool success, bytes memory data) = sender.call{value: amount}("");

        require(success, string(data));

        eligibleAmount[sender] = 0;
    }
}

You can find similar implementations of the Re-entrancy Guard modifiers from openzeppelin, etc. that have already been audited and tested and can be reused.

As a developer, it is important to be aware of this type of attack and take appropriate measures to protect your smart contracts from it.

References

[1] Re-Entrancy | Solidity By Example, solidity-by-example.org, accessed January 24th, 2023.

[2] Lines of code worth 60 million dollars in The DAO, techfi.tech, accessed January 24th, 2023.


You can find my socials below if you want to connect.

Twitter - twitter.com/sarat_angajala

Linkedin - linkedin.com/in/saratangajala

Github - github.com/in/SaratAngajalaoffl

Did you find this article valuable?

Support Sarat Angajala by becoming a sponsor. Any amount is appreciated!