首先,我将以简单的方式帮助您理解什么是重入攻击以及如何防止它,然后,我将深入研究代码示例以展示漏洞在哪里,攻击者代码是什么,最重要的是,我将向您展示最新的经过验证的方法,不仅可以保护项目中的一个智能合约,而且可以保护项目中的所有智能合约。
剧透:如果您已经听说过 nonReentrant() 修饰符,请继续阅读,因为您将发现 globalNonReentrant() 修饰符和 tests-effects-interactions 模式下面的几行。
在上图中,我们有 ContractA 和 ContractB。现在,如你所知,智能合约可以与另一个智能合约交互,例如在本例中,ContractA 可以调用 ContractB。因此,重入性的最基本思想是 ContractB 能够在 ContractA 仍在执行时回调 ContractA。
那么,攻击者如何利用这一点呢?
上面我们有 ContractA,它有 10 个 Ether,我们看到 ContractB 在 ContractA 中存储了 1 个 Ether。在这种情况下,ContractB 将能够使用 ContractA 中的 withdraw 函数,并在通过检查(其余额大于 0)后将 Ether 发送回自身,然后将其总余额修改为 0。
现在让我们看看 ContractB 如何利用重入漏洞利用 withdraw 函数并从 ContractA 窃取所有 Ether。基本上,攻击者需要两个函数:attack() 和 fallback()。
在 Solidity 中,fallback 函数是一个没有名称、参数或返回值的外部函数。任何人都可以通过以下方式调用 fallback 函数:调用合约中不存在的函数;调用函数而不传入所需数据;向合约发送不包含任何数据的 Ether。
重入的工作方式(让我们一步一步地沿着箭头走)是攻击者调用 attack() 函数,该函数内部调用 ContractA 的 withdraw() 函数。在函数内部,它将验证 ContractB 的余额是否大于 0,如果是,则继续执行。
由于 ContractB 的余额大于 0,它会将 1 个 Ether 发回,并触发 fallback 函数。请注意,此时 ContractA 有 9 个 Ether,而 ContractB 已经有 1 个 Ether。
接下来,当 fallback 函数执行时,它会再次触发 ContractA 的 withdraw 函数,再次检查 ContractB 的余额是否大于 0。如果你再次检查上面的图片,你会注意到它的余额仍然是 1 个以太币。
这意味着检查通过,它会向 ContractB 发送另一个 Ether,从而触发 fallback 函数。请注意,由于“balance=0”这一行从未执行过,因此这种情况会一直持续到 ContractA 中的所有 Ether 都用完为止。
___________
现在让我们看一下可以使用 Solidity 代码识别重入的智能合约。
在 EtherStore 合约中,我们有函数 deposit() 来存储和更新发送者的余额,然后有 withdrawAll() 函数来一次性获取所有存储的余额。请注意 withdrawAll() 的实现,它首先检查 require 余额是否大于 0,然后立即发送 Ether,同样,最后将发送者的余额更新为 0。
这里我们有一个合约 Attack,它将使用重入来耗尽 EtherStore 合约。让我们分析一下它的代码:
在其构造函数中,攻击者将传递 EtherStore 地址以创建实例并能够使用其功能。
我们可以看到,当 EtherStore 向该合约发送 Ether 时,fallback() 函数将被调用。只要余额大于或等于 1,它就会调用从 EtherStore 提取资金。
在 attack() 函数中,我们有利用 EtherStore 的逻辑。如我们所见,首先我们将通过确保我们有足够的以太币来发起攻击,然后存入 1 以太币以使 EtherStore 中的余额大于 0,从而在开始提款之前通过检查。
我在上文的 ContractA 和 ContractB 示例中一步步解释了代码的运行方式,现在我们来总结一下。首先,攻击者会调用 attack(),而 attack() 内部会从 EtherStore 调用 withdrawAll(),然后 withdrawAll() 会将 Ether 发送到 Attack 合约的 fallback 函数。然后攻击者会启动重入并耗尽 EtherStore 的余额。
那么,我们如何保护我们的合约免受重入攻击呢?
我将向您展示三种预防技术,以全面保护它们。我将介绍如何防止单个函数中的重入、跨函数的重入以及跨合约的重入。
保护单个函数的第一种方法是使用名为 noReentrant 的修饰符。
修饰符是一种特殊类型的函数,可用于修改其他函数的行为。修饰符允许您向函数添加额外的条件或功能,而无需重写整个函数。
我们在这里所做的是在执行函数时锁定合约。这样它将无法重新进入单个函数,因为它需要遍历函数的代码,然后将锁定状态变量更改为 false,以便再次通过在 require 中完成的检查。
___________
第二种技术是利用 Checks-Effects-Interactions 模式来保护我们的合约免遭跨功能重入攻击。你能发现上面更新后的 EtherStore 合约中发生了什么变化吗?
为了深入了解 Check-Effects-Interaction 模式,我建议阅读 https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html
上面我们看到了左图中存在漏洞的代码的比较,其中余额在发送以太币后更新,如上所示,这可能永远无法达到,而右边的代码所做的就是将 balances[msg.sender] = 0(或效果)移到 require(bal > 0)(检查)之后但在发送以太币(交互)之前。
这样,我们可以确保即使另一个函数正在访问 drawAll(),该合约也将受到保护,免受攻击者攻击,因为在发送以太币之前余额将始终更新。
图案由 https://twitter.com/GMX_IO 创建
我要向您展示的第三种技术是创建 GlobalReentrancyGuard 合约来防止跨合约重入。重要的是要理解,这适用于具有多个相互交互的合约的项目。
这里的想法与我在第一种技术中解释过的 noReentrant 修饰符相同,它进入修饰符,更新一个变量以锁定合约,直到代码完成才会解锁。这里最大的区别是,我们使用存储在单独合约中的变量,该变量用作检查函数是否已进入的地方。
我在这里创建了一个示例,没有实际的代码,只有函数名称作为参考来理解这个想法,因为根据我的经验,它可以帮助形象化情况,而不仅仅是用文字写出来。
在这里,攻击者将调用 ScheduledTransfer 合约中的函数,该函数在满足条件后会将指定的 Ether 发送到 AttackTransfer 合约,因此 AttackTransfer 合约将进入 fallback 函数,从而从 ScheduledTransfer 合约的角度“取消”交易,但仍会收到 Ether。这样,它就会开始观察,直到耗尽 ScheduledTransfer 中的所有 Ether。
好吧,使用我上面提到的 GlobalReentrancyGuard 就可以避免这种攻击场景。
__________________
Twitter @TheBlockChainer 可以找到更多关于智能合约、Web3 安全、Solidity、审计智能合约等的每日更新。
__________________