Ethernaut Level 10 Reentrancy [Foundry-Hardhat]

Ethernaut Level 10 Reentrancy [Foundry-Hardhat]

The Ethernaut-Solutions repository contains the solutions using Foundry and Hardhat.

Objectives

  • Steal all the funds from the contract. In this level, you will learn about re-entrancy attacks.

Reentrancy

Whenever Ether is sent to a smart contract, either the receive() or payable fallback() function is called. Any interaction from contract A to contract B and any transfer of Ether hands control over to contract B. This allows B to call back into A before the interaction is finished and can lead to an infinite loop, allowing the attacker to perform malicious actions or drain the contract's resources.

contract Fund {
    mapping(address => uint) shares;
    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}("");
        if (success)
            shares[msg.sender] = 0;
    }
}

In the above code, first, the transfer of ether takes place, then the shares is updated. It is possible for the attacker to reenter the withdraw() function before the shares is updated. If msg.sender is a smart contract, and it receives Ether without calling a function, either the receive() or the payable fallback() function is executed. During the execution of one of these functions, the contract can only rely on the “gas stipend” (2300 gas) it is passed being available to it at that time if transfer() or send() is used (do not take this for granted though, the stipend might change with future hard forks). This stipend is not enough to modify storage, so it is safe. However, if call() is used to send Ether, by default, it forwards all remaining gas and allows the recipient to perform more expensive actions.

An attacker can use the receive() function to reenter the vulnerable contract recursively, calling withdraw() multiple times without updating the state, allowing the attacker to extract more funds than the attacker's balance.

Fallback functions can be called by anyone and execute malicious code, allowing malicious external contracts to abuse withdrawals.

See Re-Entrancy Vulnerability.


Analysis

Take a look at withdraw() the function of the level contract,

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    (bool result,) = msg.sender.call{value:_amount}("");
    if(result) {
      _amount;
    }
    balances[msg.sender] -= _amount;
  }
}

The function checks if the balance of the address that initiated the transaction (msg.sender) is greater than the amount. Then it makes an external call to msg.sender, transferring the Ether equal to the amount. After the Ether transfer, the balance is updated.

This is a classic re-entrancy vulnerability. The user controls the msg.sender address. Note that an external call occurs before the state-changing code.

We will create a malicious contract that will donate and withdraw Ether from the re-entrance contract. When withdraw() function is called, it invokes receive() function of our contract before resetting the balance. We can invoke withdraw() function again in receive() function, allowing us to enter the contract again and execute another withdrawal before updating the state.


Exploit

Let’s create a contract that will hack the level.

Go to the Remix IDE and create the following contract.

The ReentranceHack contract's hack() function calls donate() to send funds to ReentranceHack then calls withdraw() to attempt to withdraw funds from the ReentranceHack contract. When withdraw() is called, it will trigger the contract's receive() function, allowing the ReentranceHack contract to attempt to withdraw more funds from the reentrance contract.

This process can be repeated indefinitely, potentially allowing the attacker to drain all of the funds from the reentrance contract. To prevent this type of attack, it is important to carefully design and test smart contracts to ensure that they are not vulnerable to reentrancy attacks.

  • Check the contract balance before the attack. It is greater than 0.

      await web3.eth.getBalance(instance)
    
  • Deploy the contract, passing the instance address to the constructor. Get the instance address by typing instance in the dev console on the level page.

  • Call hack() function, passing instance address as an argument. Send 0.001 ether i.e. 1000000 gwei in the value field. The contract’s balance is already 0.001 ether. Donate 0.001 ether so that the reentrancy attack would be completed in 2 reentrant calls.

  • Check the balance of the level contract. This will return '0'.

      await web3.eth.getBalance(instance)
    

Submit the instance.

Level passed!!!😄


Key Takeaways

  • Always assume that the receiver of the funds you are sending can be another contract, not just a regular address. Make sure that the external call is the last thing happening in the contract after all the state changes.

  • Make sure all internal state changes are performed before the call is executed. This is known as the Checks-Effects-Interactions pattern

  • Use OpenZeppelin’s ReentrancyGuard.

  • Use a pull-payment strategy.

  • If you are using a low-level call() function, then limit the gas forwarded using the gas option. (eg. address(contract.call{gas: 50000}(“”))). Remember to check the return value.

  • Note that re-entrancy is not only an effect of Ether transfer but of any function call on another contract. Furthermore, you also have to take multi-contract situations into account. A called contract could modify the state of another contract you depend on.

  • transfer and send are no longer recommended solutions as they can potentially break contracts after the Istanbul hard fork Source 1 Source 2.


The Ethernaut-Solutions repository contains the solutions using Foundry and Hardhat.

Solution using Foundry:-

Solution using Hardhat:-

More Levels

Did you find this article valuable?

Support Chirag Patil by becoming a sponsor. Any amount is appreciated!