未检查的低级调用
这一讲,我们将介绍智能合约的未检查低级调用(low-level call)的漏洞。失败的低级调用不会让交易回滚,如果合约中忘记对其返回值进行检查,往往会出现严重的问题。
低级调用
以太坊的低级调用包括 call()
,delegatecall()
,staticcall()
,和send()
。这些函数与 Solidity 其他函数不同,当出现异常时,它并不会向上层传递,也不会导致交易完全回滚;它只会返回一个布尔值 false
,传递调用失败的信息。因此,如果未检查低级函数调用的返回值,则无论低级调用失败与否,上层函数的代码会继续运行。对于低级调用更多的内容,请阅读 WTF Solidity 极简教程第20-23讲。
最容易出错的是send()
:一些合约使用 send()
发送 ETH
,但是 send()
限制 gas 要低于 2300,否则会失败。当目标地址的回调函数比较复杂时,花费的 gas 将高于 2300,从而导致 send()
失败。如果此时在上层函数没有检查返回值的话,交易继续执行,就会出现意想不到的问题。2016年,有一款叫 King of Ether
的链游,因为这个漏洞导致退款无法正常发送(验尸报告)。
漏洞例子
银行合约
这个合约是在S01 重入攻击
教程中的银行合约基础上修改而成。它包含1
个状态变量balanceOf
记录所有用户的以太坊余额;并且包含3
个函数:
deposit()
:存款函数,将ETH
存入银行合约,并更新用户的余额。withdraw()
:提款函数,将调用者的余额转给它。具体步骤和上面故事中一样:查询余额,更新余额,转账。注意:这个函数没有检查send()
的返回值,提款失败但余额会清零!getBalance()
:获取银行合约里的ETH
余额。
1 | contract UncheckedBank { |
攻击合约
我们构造了一个攻击合约,它刻画了一个倒霉的储户,取款失败但是银行余额清零:合约回调函数 receive()
中的 revert()
将回滚交易,因此它无法接收 ETH
;但是提款函数 withdraw()
却能正常调用,清空余额。
1 | contract Attack { |
Remix
复现
- 部署
UncheckedBank
合约。 - 部署
Attack
合约,构造函数填入UncheckedBank
合约地址。 - 调用
Attack
合约的deposit()
存款函数,存入1 ETH
。 - 调用
Attack
合约的withdraw()
提款函数,进行提款,调用成功。 - 分别调用
UncheckedBank
合约的balanceOf()
函数和Attack
合约的getBalance()
函数。尽管上一步调用成功并且储户余额被清空,但是提款失败了。
预防办法
你可以使用以下几种方法来预防未检查低级调用的漏洞:
检查低级调用的返回值,在上面的银行合约中,我们可以改正
withdraw()
。1
2bool success = payable(msg.sender).send(balance);
require(success, "Failed Sending ETH!")合约转账
ETH
时,使用call()
,并做好重入保护。使用
OpenZeppelin
的Address库,它将检查返回值的低级调用封装好了。
总结
我们将介绍未检查低级调用的漏洞及其预防方法。以太坊的低级调用(call, delegatecall, staticcall, send)在失败时会返回一个布尔值 false,但不会让整个交易回滚。如果开发者没有对它进行检查的话,则会发生意外。