重入攻击和 DAO 被黑事件
重入攻击,在 The DAO 被黑的事件中被使用过,主要是开发者写的 Solidity 代码的一些漏洞造成的。
在这篇文章中,我们会了解在以太坊早期,最出名的一次通过 Solidity 代码漏洞进行的黑客攻击。这次事件中,黑客攻击了一个叫做 The DAO 的 DAO(去中心化自制组织),这次攻击事件中用的方法通常被称为重入攻击。
前置知识
理解这个攻击前你需要了解以下内容:
- 区块链技术的基础知识,特别是以太坊。
- 以太坊虚拟机(EVM):在以太坊节点上运行的去中心化的,互相同步的状态器。
- 在以太坊语境下,智能合约指的是通过 Solidity 语言编写的软件代码,在 EVM 上执行。
- 在以太坊语境下,“账户”这个词指的是一个有 ether 余额的主体,可以在以太坊网络上发送交易,有两种类型:用户控制的和已经部署的智能合约。
你不需要有任何关于 Solidity 的知识,因为代码的例子很简单。对于任何编程语言的基础知识都可以帮助理解。
你也可以随着此处的重入攻击教程一起编程。
关于 The DAO 被攻击事件的简介
在 2015 年之前,还在早期的以太坊社区就开始讨论 DAO(Decentralized Automated Organization)了。DAO 想要做到的是通过可验证的代码(具体来说,就是运行在以太坊区块链上的智能合约)来实现人与人之间的协作,同时通过社区的协议来进行去中心化的决策。在 2016 年,也就是以太坊主网运行了一年以后,一个名叫 “The DAO” 的 DAO 被创建了。它是一个去中心化的,由社区控制的投资基金。它通过销售自己的社区通证募集了价值 1 亿 5000 万的美元的 ether(大概有 354 万 ETH)。人们通过存储 ETH 来购买 The DAO 的社区通证,这些存储在 The DAO 中的 ETH 就变成了投资基金。The DAO 会代表持有社区通证的投资者来进行投资。
因为当时正处在以太坊、智能合约、DAO发展的很早期,所以这些前所未有的组织和协调人类活动的方式令人兴奋不已。
然而不幸的是,在 The DAO 开始还不到三个月的时间里,就被一个“黑帽”黑客攻击了。在接下来的几周里,这个黑客从 The DAO 的智能合约中偷走了价值 1 亿 5000 万美元的 ETH。这个黑客的攻击方式被成为“重入”攻击。“重入”这个名字一定程度上描述了攻击的方式,在后面我深入了解。正如你想象的一样,这次攻击对 DAO 进行了非常严重的破坏,使其失去了投资者的信任,同时也严重影响了以太坊的信誉。
行业内的参与者和评论员都看到了资金在 The DAO 中被偷走,并就如何处理这次事件进行了激烈的讨论。一部分人认为,密码学保证了区块链的不可篡改,如果强行修改,即使是为了正确的原因,也属于篡改。一个真正的去信任和防篡改系统应该是不能被外界强行干预的,即使不干预的后果很严重也不能干预,这个严重的后果也是实现去中心化,防篡改这些特点所需要付出的代价。
而另一方面,有人觉得人们在 The DAO 中资产正在缓慢地偷走,这会破坏公众的信心。为了阻止严重的后果,大家有责任去阻止资产被盗。
在这些讨论进行的时候,一个“白帽”黑客组织进行反击。他们属于要干预的阵营,他们使用黑客的同样手段进行重入攻击,尝试比黑客更快地把 The DAO 的资金转走。他们想要拯救这笔资金,然后返还给投资者。大量的资金被返回给了投资者,这样很多投资者就能够通过这个“逃生舱”取回他们的投资。
同时,因为黑客将大量的资金盗走的行为还在继续,以太坊核心团队面临一个艰难的决策。一种阻止黑客的方式是分叉以太坊,这样就可以修改历史,让这个事件没有发生过。在这个例子中,通过分叉以太坊,黑客在攻击中获得的 ETH 只会存在于以前的旧的网络中。如果用户都接受了新的分叉而把旧的网络废除的话,黑客偷走的 ETH 将不再值钱。虽然这次分叉将会让黑客攻击发生的那些区块不再有效,但是这个极端的操作将会完全违背以太坊的原则:这种干预正是以太坊自身想要避免的一种中心化的,单方面的行为。
那些投票给分叉的人也同意同时有两条以太坊区块链,这个意愿占到了总投票的 85%,然后分叉就发生了(尽管矿工抵制这个做法,因为以太坊合约没有任何问题,这是人的疏忽)。这也就是为什么现在有两个以太坊链 – 以太经典和我们今天在用的以太坊。它们都有原生 ETH 通证,当时这些通证在市场上的价格差别很大。你可以在这里查看以太坊基金会在的声明。
The DAO 在历史上非常重要,基于 The DAO 的黑客事件和最终的决策同样影响了历史。但是究竟黑客是如何攻击的?让我们一起了解一下。
Solidity 中的重入攻击是什么?
运行在以太坊区块链上的应用被叫做“智能合约”(虽然叫合约,但是它们其实没有任何法律效应)。智能合约是一些代码,最常使用的语言叫做 Solidity,它们在区块链上被执行,可以和用户账户和其他部署在以太坊上的智能合约交互。这些合约之间的交互是整个设计中最重要的一点,账户和账户之间的转账也是最重要的设计之一。这些特性都是通过以太坊虚拟机执行 Solidity 代码来体现。
重入攻击通过一个叫做 “fallback” 的函数执行。Fallback 函数是 Solidity 中一个特殊的结构,在某些特殊的场景下会被触发。fallback 函数的功能有下面这些特点:
- 它们是不命名的。
- 它们是被外部调用的(它们不能够被自己合约内的函数调用)。
- 一个合约中只有 0 个或者 1 个 fallback 函数,不会更多。
- 它们会在别的合约调用一个本合约中不存在的函数时被调用。
- 当 ETH 被发送给这个合约的时候,如果该交易没有 calldata 同时合约中没有 receive() 函数时,fallback 函数会被触发。在这个场景下,fallback 必须被标记为 payable 以使它可以被触发并且接受 ETH。
- Fallback 函数可以包含自己的逻辑。
就是因为第五个和第六个特性,导致 fallback 函数被重入攻击。攻击同时也依赖于被攻击合约的某些代码执行顺序。让我们一起了解以下它是怎么发生的。
在下述的描述中,红色和绿色的盒子是智能合约,同时为了让它更有趣,我将基于 The DAO 被攻击事件来演示重入攻击。这是一个简化版本,只是为了了解重入攻击,下面的代码和 The DAO 的实际代码也不一样。
在之前的描述中,The DAO 的智能合约有一个状态变量叫做 Balances,用来记录所有投资者的在 The DAO 中的投资。这个和合约的 ETH 余额是分开的,ETH 余额没有存放在状态变量中。
黑客部署了一个合约,作为“投资者”在 The DAO 中储存一些 ETH。然后黑客去调用The DAO 合约中的 withdraw() 函数。当 withdraw() 函数被调用,The DAO 合约会给黑客的合约发送 ETH。但是黑客的合约中没有 receive() 函数,所以当它接收到 withdraw 请求中的 ETH 时,黑客合约中的 fallback() 函数就被触发了。这个 fallback 函数可以没有逻辑,只接受 ETH,但是黑客合约中 fallback 却包含一些恶意代码。
这些恶意代码,在被执行的时候,再次调用 The DAO 的智能合约的 withdraw() 函数。这会开始一个循环调用,因为这时候第一个调用仍然在执行。它只有在黑客合约中的 fallback 函数完成以后才能够完成执行,但是 withdraw() 函数在黑客合约中的 fallback 函数中再次被调用,这样就开始了一个黑客合约和 The DAO 合约之间的循环。
每次 withdraw() 被调用的时候,The DAO 就会给黑客合约发送与它存储等值的 ETH。但是,关键是黑客的账户余额只有在发送 ETH 的交易完成以后才会修改。但是发送 ETH 的合约只有在黑客的 fallback 函数执行完成以后才能结束。所以 The DAO 的合约持续不断给黑客的合约发送 ETH,同时又不修改余额,因此会提空 The DAO 的所有资金。
看一下下面的代码,可能会更好理解一点。
重入攻击代码样例
让我们开始看 The DAO 的代码,代码中的一个执行顺序导致了这个漏洞。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Dao { mapping(address => uint256) public balances; function deposit() public payable { require(msg.value >= 1 ether, "Deposits must be no less than 1 Ether"); balances[msg.sender] += msg.value; } function withdraw() public { // Check user's balance require( balances[msg.sender] >= 1 ether, "Insufficient funds. Cannot withdraw" ); uint256 bal = balances[msg.sender]; // Withdraw user's balance (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to withdraw sender's balance"); // Update user's balance. balances[msg.sender] = 0; } function daoBalance() public view returns (uint256) { return address(this).balance; } }
注意下面的点:
- 这个智能合约有一个投资者地址和 ETH 余额的 mapping。投资的 ETH 都被记录在合约的余额中,这个于合约的状态变量 balances 不一样。
- deposit() 函数要求最小的金额是 1 ETH,当投资金额收到以后,会增加投资者的余额。
- withdraw() 函数在把余额变为 0 之前,被取出的 ETH 发给投资者(使用 msg.sender.call)。发送 ETH 的交易只有在黑客合约的 fallback 函数完成执行之后才可以结束,所以黑客的余额只有在 fallback 函数结束以后才会被置 0。这就是 The DAO 合约的最大漏洞。
现在让我们看一下发动了重入攻击的智能合约。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; interface IDao { function withdraw() external ; function deposit()external payable; } contract Hacker{ IDao dao; constructor(address _dao){ dao = IDao(_dao); } function attack() public payable { // Seed the Dao with at least 1 Ether. require(msg.value >= 1 ether, "Need at least 1 ether to commence attack."); dao.deposit{value: msg.value}(); // Withdraw from Dao. dao.withdraw(); } fallback() external payable{ if(address(dao).balance >= 1 ether){ dao.withdraw(); } } function getBalance()public view returns (uint){ return address(this).balance; } }
注意下面的点:
- attack() 函数将黑客的“投资”存到了 The DAO 之中,然后通过调用 The DAO 合约的 withdraw() 函数开始攻击,就像我们之前所说的一样。
- Fallback 函数包含了恶意代码,它会检查 The DAO 合约中是否还有 ETH 剩余,然后调用 The DAO 合约的 withdraw() 函数。我们在之前看到了因为发送 ETH 的交易还没有完成,所以 The DAO 合约的 withdraw() 函数并没有更新账户余额。这个交易一直在被执行是因为黑客的 fallback 函数持续调用 withdraw()。这样就在不改变 balances 这个状态变量的情况下,提空 The DAO 合约中所有的余额。
- 一旦 The DAO 合约的 ETH 余额被提空,这个 fallback() 函数就不会再执行 withdraw() 函数了,然后 fallback() 函数的执行就会完成。只有这个时候,黑客的账户余额会置 0,同时 The DAO 也没有任何 ETH 了。
修复重入攻击漏洞
有几种方法去修复重入攻击的漏洞,但是在我们的例子中,最简单的修复方法是改变 The DAO 合约中 withdraw() 函数的执行顺序以让调用者的余额在 The DAO 合约给它们发送 ETH 之前置 0。就像下面的代码一样:
Contract Dao { … function withdraw() public { // Check user's balance require( balances[msg.sender] >= 1 ether, "Insufficient funds. Cannot withdraw" ); uint256 bal = balances[msg.sender]; // Update user's balance. balances[msg.sender] = 0; // Withdraw user's balance (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to withdraw sender's balance");// Update user's balance.balances[msg.sender] = 0;} }
通过这个方式,当更底层的 call() 函数触发黑客合约的 fallback() 函数以后,这个函数尝试重入 withdraw() 函数时,黑客的余额在重入的时候就已经是 0 了,require() 函数会判定为 false,因此在这里就会 revert 这个交易。这会让最开始调用 fallback 的交易直接 return,因为交易失败,所以 sent 的值返回 false,下一行的代码(require(sent, “Failed to withdraw sender’s balance”);) 就会 revert。
黑客只能够取回他自己存的钱,但是不会有更多了。
另一个方法是 The DAO 合约使用函数修改器,将 withdraw() 函数“锁住”,让它在被重入的时候被这个锁挡住。我们可以通过给 The DAO 合约加入以下代码来实现这一点。
Contract Dao { bool internal locked; modifier noReentrancy() { require(!locked, "No reentrancy"); locked = true; _; locked = false; } //…… function withdraw() public noReentrancy { // withdraw logic goes here… } }
这个重入守护使用了mutex (mutually exclusive) flag 模式来保护 withdraw() 函数,防止它在上一次调用还没有完成的情况下被再次调用。所以当黑客合约的 fallback() 函数尝试再次通过 withdarw() 函数进入 The DAO 的时候,这个函数修改器就会被触发,同时它的require 函数会 revert 并且返回信息“No reentrancy”。
总结
这篇文章用了一个非常简单的例子,解释来重入攻击的概念。尽管我使用了 The DAO 的事件作为背景去解释重入攻击,但是 The DAO 的代码是不同的。然而,The DAO 通过“重入”这个概念被攻击的,因为黑客在不更新余额的条件下,通过递归的方式取出资产。你可以在 The DAO 的 GitHub repo 中查看,并且在 commit history 中了解这个漏洞是如何被修复的。
您可以关注 Chainlink 预言机并且私信加入开发者社区,有大量关于智能合约的学习资料以及关于区块链的话题!