重入攻击是智能合约最常见的攻击之一,理解好重入攻击使得写出solidity合约代码更具有安全性。重入攻击曾导致以太坊分叉为ETH和ETC(经典)。接下来将逐步深入介绍重入攻击。
一些著名的重入攻击事件:
- 2016年,The DAO合约被重入攻击,黑客盗走了合约中的 3,600,000 枚
ETH
,并导致以太坊分叉为ETH
链和ETC
(以太经典)链。 - 2019年,合成资产平台 Synthetix 遭受重入攻击,被盗 3,700,000 枚
sETH
。 - 2020年,借贷平台 Lendf.me 遭受重入攻击,被盗 $25,000,000。
- 2021年,借贷平台 CREAM FINANCE 遭受重入攻击,被盗 $18,800,000。
- 2022年,算法稳定币项目 Fei 遭受重入攻击,被盗 $80,000,000。
距离 The DAO 被重入攻击已经6年了,但每年还是会有几次因重入漏洞而损失千万美元的项目,因此理解这个漏洞非常重要。
一、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 会代表持有社区通证的投资者来进行投资。
在The DAO 开始不到三个月的时间里,就被一个“黑帽”黑客攻击了。在接下来的几周,这个黑客从The DAO中被偷走了价值1亿5000万美元的ETH。这种攻击方式被称为“重入攻击”。这次攻击对 DAO 进行了非常严重的破坏,使其失去了投资者的信任,同时也严重影响了以太坊的信誉。
行业内的人都看到了资金在 The DAO 中被偷走,并就如何处理这次事件进行了激烈的讨论。一部分人认为,密码学保证了区块链的不可篡改,如果强行修改,即使是为了正确的原因,也属于篡改。另一方面,有人觉得人们在 The DAO 中资产正在缓慢地偷走,这会破坏公众的信心。为了阻止严重的后果,大家有责任去阻止资产被盗。
在这些讨论进行的时候,一个“白帽”黑客组织进行反击,他们属于要干预的阵营,他们使用黑客的同样手段进行重入攻击。尝试比黑客更快地把The DAO的资金转走,他们想要拯救这笔资金,然后返还给投资者。大量的资金被返回给了投资者,这样很多投资者就能够通过这个“逃生舱”取回他们的投资。
因为黑客将大量的资金盗走的行为还在继续,以太坊核心团队面临一个艰难的决策。一种阻止黑客的方式是分叉以太坊,这样就可以修改历史,让这个事件没有发生过。在这个例子中,通过分叉以太坊,黑客在攻击中获得的 ETH 只会存在于以前的旧的网络中。如果用户都接受了新的分叉而把旧的网络废除的话,黑客偷走的 ETH 将不再值钱。虽然这次分叉将会让黑客攻击发生的那些区块不再有效,但是这个极端的操作将会完全违背以太坊的原则:这种干预正是以太坊自身想要避免的一种中心化的,单方面的行为。
那些投票给分叉的人也同意同时有两条以太坊区块链,这个意愿占到了总投票的 85%,然后分叉就发生了(尽管矿工抵制这个做法,因为以太坊合约没有任何问题,这是人的疏忽)。这也就是为什么现在有两个以太坊链 – 以太经典和我们今天在用的以太坊。它们都有原生 ETH 通证,当时这些通证在市场上的价格差别很大。
二、什么是重入攻击?
重入攻击通过一个叫做“fallback”的函数执行。Fallback函数是solidity中一个特殊的结构,在某些特殊的场景下会被触发。fallback()功能有下面这些特点:
1.他们是不被命名的
2.他们是被外部调用,不能被自己合约内的函数调用
3.一个合约中只有0个或者1个fallback函数,不会更多。
4.他们会在别的合约调用一个本合约不存在的函数时调用
5.当ETH被发送给这个合约时,如果该交易没有calldata同时没有receive()函数时,fallback函数会被触发。并且fallback函数必须标记为payable以便可以接受ETH
6.fallback函数可以包含自己的逻辑
上面的第五个和第六个特性,导致fallback函数被重入攻击
三、代码示例
银行合约(被攻击合约)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;
contract Bank{
//地址=>余额
mapping(address => uint) balance;
//充值
function deposit() public payable{
balance[msg.sender] += msg.value;
}
//提款msg.sender的全部ether
function withdraw() public {
require(balance[msg.sender] > 0);
//这里具有重入攻击的风险
(bool success,) = msg.sender.call{value: balance[msg.sender]}("");
require(success);
balance[msg.sender] = 0;
}
//获取本合约的余额
function getBalance() public view returns(uint){
return address(this).balance;
}
}
复制代码
攻击合约
contract Attack{
Bank bank;
constructor(address _bank) payable{
bank = Bank(_bank);
}
fallback() external {
if(bank.getBalance() > 1 ether){
bank.withdraw();
}
}
function attack() public payable{
bank.deposit{value: 1 ether}();
bank.withdraw();
}
}
复制代码
- attack()
先给自己账户充值ETH,才可以提取金额,调用bank合约的withdraw方法获得ETH
- fallback()
先判断bank合约中是否还有ETH,如果有,则调用withdraw方法,这里会无限次调用withdraw函数,直到合约里面没有钱
该案例重入攻击的逻辑
1.先部署Bank合约,调用deposit函数并充值
2.部署Attack合约
3.在attack()函数中调用了Bank合约的withdraw(),此次转向Bank合约,在withdraw()方法中的msg.sender.call()这一行,call()函数会找Attack合约中的receive(),若没有则看是否有fallback(),在我们这个攻击合约中,有fallback(),则call()函数会调用fallback()。
4.在本来的fallback()函数中是写的正确的逻辑,不会导致重入攻击,但我们这个攻击合约中的fallback()中的逻辑会导致一直withdraw,直到将全部的钱转走。
四、如何修复
方法一
最简单的方式是将被攻击的合约Bank合约里面的withdraw()方法balance[msg.sender] = 0
这一行放到 (bool success,) = msg.sender.call{value: balance[msg.sender]}("")
上面。更改后的函数如下:
//提款msg.sender的全部ether
function withdraw() public {
require(balance[msg.sender] > 0);
balance[msg.sender] = 0;
(bool success,) = msg.sender.call{value: balance[msg.sender]}("");
require(success);
}
复制代码
这样,当call函数触发攻击合约中的fallback函数时,尝试调用withdraw方法,是不可行的,因为此时的余额已经为0了,require()函数就为false,黑客只能取回自己的钱,不会有更多的钱了。
方法二
使用锁机制,添加一个函数修改器modifier,用于防止重入攻击的修饰器,包含一个默认变量_status
,在第一次调用withdraw函数时,会先判断_status
是否为01,如果是,将其置为1,表示此时正在调用函数,还未结束,当攻击合约攻击时,_status
还是1,函数并未结束,攻击合约调用就会报错,重入攻击失败。
uint256 private _status; // 重入锁
// 重入锁
modifier Lock() {
// 在第一次调用 Lock时,_status 将是 0
require(_status == 0, "Lock: reentrant call");
// 在此之后对 Lock 的任何调用都将失败
_status = 1;
_;
// 调用结束,将 _status 恢复为0
_status = 0;
}
//提款msg.sender的全部ether
function withdraw() public Lock{
require(balance[msg.sender] > 0);
balance[msg.sender] = 0;
(bool success,) = msg.sender.call{value: balance[msg.sender]}("");
require(success);
}
复制代码
评论