重入攻打,在 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 预言机并且私信退出开发者社区,有大量对于智能合约的学习材料以及对于区块链的话题!