本文转自公众号:亨利笔记
首发于: https://hash1024.org/topics/27
本文的英文原文已有若干个中文翻译版本,但译文和原文都有错漏或不足。本文系基于译者(Henry)的理解,重新翻译并做了修正,并且加入了个人的注解和插图,力求让读者更容易领会原文的含义。为不影响阅读体验,注解没有单独标出, 可当译文 +“笔记”来阅读。感兴趣的读者可参考文末的原文地址。
虽然仍然处于起步阶段,但 Solidity 已被广泛采用,成为事实上的智能合约标准,新的区块链项目不少都兼容了 Solidity 语言, Solidity 已经用于编写了大量的以太坊智能合约。由于语言和EVM的细微差别,开发人员和用户都体会到了许多深刻的经验教训。本文目的是作为相对深入和最新的介绍性文章,详细阐述 Solidity 开发人员如何吸取前人踩坑的教训,避免重蹈覆辙。
重入问题(Re-Entrancy)
以太坊智能合约能够调用和利用其他外部合约的代码。合约通常也处理以太币,因此将以太币发送到各种外部用户地址。调用外部合约或将以太币发送到地址的操作要求合约提交外部调用。这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback 回退函数),包括回调原合约本身。所以,合约代码执行过程中将可以“重入”该合约,有点像编程语言里面的间接递归函数调用。在臭名昭著的The DAO事件中黑客使用了这种攻击,最终导致了以太坊的硬分叉。
漏洞细节
当合约将以太币发送到未知地址时,可能会发生此攻击。攻击者可以在外部地址小心地构建合约,该地址包含回退函数中的恶意代码。因此,当合约把以太币发送到此地址时,将**恶意代码。通常,恶意代码在易受攻击的合约上执行函数,是开发人员没有预期的操作。
“Re-entrancy”的名称来自这样的现实:外部恶意合约回调了受攻击合约上的一个函数,并在受攻击合约上的任意位置“重新进入”代码执行。因为原合约的程序员可能没有预料到合约代码可以被“重入”,因此合约会出现不可预知的行为。
为了说明这一点,考虑以下简单易受攻击的合约,该合约充当以太坊金库,允许存款人每周仅提取最多1个以太币。
EtherStore.sol:
该合约有两个公共函数:depositFunds() 和 withdrawFunds() 。depositFunds() 函数只是累计发送者余额。withdrawFunds() 函数允许发送者指定要提取的以太币数量(wei为单位)。只有当要求提取的金额小于或等于1个以太币、并且在一周内没有发生提取时,它才会成功。真的是这样吗?
漏洞出现在第17行,我们向用户发送他们要求的以太数量。考虑一个恶意攻击者创建以下合约:
Attack.sol:
我们来看看这个恶意合约如何利用 EtherStore 合约。攻击者将使用 EtherStore 的合约地址作为构造函数参数创建上述合约(假设在地址0x0 … 123处)。这将初始化并将公共变量 etherStore ,使其指向希望攻击的合约地址。
然后攻击者将调用 pwnEtherStore() 函数,并使用一些以太币(大于或等于1),例如1个以太币。在这个例子中,我们假设许多其他用户已将以太币存入此合约,这样它的当前余额为10个以太币。然后会发生以下情况:
Attack.sol - 第15行- EtherStore 合约的 depositFunds() 函数将被调用,其中msg.value 为1 Ether(以及大量的Gas)。发件人(msg.sender)将是我们的恶意合约(0x0 … 123)。因此,balances[0x0..123] = 1Ether。
Attack.sol - 第17行- 然后恶意合约将使用1 ether的参数调用EtherStore合约的withdrawFunds()函数。这将通过所有require语句(EtherStore合约的第[12] - [16]行),因为我们之前没有提取过。
EtherStore.sol - 第17行- 然后合约将1以太币发回恶意合约。
Attack.sol - 第25行- 发送给恶意合约的以太币将执行回退函数。
Attack.sol - 第26行- EtherStore 合约的总余额为10个以太币,现在为9个以太币,因此if语句通过。
6.Attack.sol - 第27行– 回退函数再次调用 EtherStore 的 withdrawFunds() 函数并“重新进入” EtherStore 合约。
EtherStore.sol - 第11行- 在第二次调用 withdrawFunds() 时,我们的余额仍为1以太,因为第18行尚未执行。因此,我们仍然有 balances[0x0..123] = 1 Ether。lastWithdrawTime 变量也是如此。我们再次通过了所有要求。
EtherStore.sol - 第17行- 我们提取另外1个以太币。
步骤4-8将重复- 直到 EtherStore.balance<= 1,如 Attack.sol 中的第26行所示。
Attack.sol - 第26行 - 一旦 EtherStore 合约中剩下不多于1(或更少)的ether,则此if语句将失败。然后,这将允许执行 EtherStore 合约的第18和19行(对于withdrawFunds()函数的每次调用)。
EtherStore.sol – 第18和19行- 将设置 balances 和 lastWithdrawTime 映射,执行将结束。
最终的结果是,攻击者通过这笔交易,立即从 EtherStore 合约中提取了所有以太币(只留下不超过1个以太币)。
预防技术
程序员写合约时需要留个心眼,提防合约重入的可能性,采用一些技术避免智能合约中潜在的重入漏洞。
第一种技术是(在可能的情况下)将 ether 发送到外部合约时使用内置的 transfer() 函数。transfer() 函数仅发送 2300 Gas 给外部调用,这不足以使目的地址合约调用另一个合约(即重入原合约)。
第二种,是确保所有改变状态变量的逻辑,都发生在以太币被发送出合约(或任何外部调用)之前。在 EtherStore 示例中,EtherStore.sol 的第18和19行应放在第17行之前。最好将对未知地址的外部调用,作为本地函数或代码的最后一个操作。这在以太坊文档中称为检查-效果- 交互(checks-effects-interactions)模式。
第三种是引入互斥锁。也就是说,添加一个状态变量,在代码执行期间锁定合约,防止重入调用。
将所有这些技术(不是全部都需要,但是为了演示目的都使用了)应用于EtherStore.sol,得到以下无重入漏洞的合约:
真实例子:The DAO事件
The DAO(Decentralized Autonomous Organization, 去中心化自治组织)是以太坊早期发展中发生的主要黑客事件之一。当时,合约持有超过1.5亿美元的以太币。重入在攻击中发挥了作用,最终导致了以太坊经典(ETC)的硬分叉。
关于The DAO攻击的完整始末,请参看公众号亨利笔记2016年的原创文章介绍:
道or悼?三分钟看懂史上最逆天的区块链众筹项目The DAO
参考文献:
英文原文:
https://blog.sigmaprime.io/solidity-security.html
欢迎扫码加入哈希1024社区微信群,进行区块链技术交流(群满可加管理员:aristark 注明:入群)