译文出自:登链翻译计划 [1]
译者:翻译小组 [2]
校对:Tiny 熊 [3]
由于 EIP 1884[4] 已经在伊斯坦布尔硬分叉 [5] 实施,EIP 1884[6] 增加了
SLOAD
操作的 Gas 成本,因此_破坏了一些现有的智能合约_[7]。
这些合约将被破坏,因为它们的 fallback 函数 [8] 以前消耗的 Gas 不到 2300,而现在会消耗更多。为什么 2300 Gas 这么重要?这是合约的 fallback 函数通过 Solidity 的
transfer()
或
send()
方法 [9] 调用时可使用的 Gas 量。
刚才是简化的描述, 2300 是 Gas ”津贴“,如果是非零的以太币量转账,则 Gas ”津贴“ 明确传递给
CALL
。Solidity 的transfer()
将 Gas 参数设置为 0,如果以太币的转账量为非零。在加上 gas”津贴“后,一共是 2300 。如果是零以太币转账,Solidity 明确地将 Gas 参数设置为 2300,因此在两种情况下都会是 2300 Gas。
自推出以来,
transfer()
通常被安全界推荐,因为它有助于防范重入攻击。在 Gas 成本不会改变的假设下,这一指导意见是有意义的,但事实证明这一假设是不正确的。我们现在建议避免使用
transfer()
和
send()
。
Gas 成本可以改变
EVM 支持的每个操作码都有相关的 Gas 成本。例如,
SLOAD
,从存储中读取一个字,在 EIP 1884 中 gas 由 200 修改为 800 。
Gas 费用不是随意的。它们旨在反映组成以太坊的节点上每个操作所消耗的基本资源。
来自 EIP 的动机部分 [10]。
操作的价格和资源消耗(CPU 时间、内存等)之间的不平衡有几个缺点:
可能被用于攻击,通过用低 Gas 操作填充区块,导致区块处理时间过长。
价格过低的操作码会歪曲区块 Gas 限制 ,有时区块完成得很快,但其他 Gas 使用量相似的区块完成得很慢。
如果操作定价更均衡,我们可以最大限度地提高块 Gas 限制,并有一个更稳定的处理时间。
SLOAD
历来价格偏低,EIP 1884 纠正了这一问题。
智能合约不能依赖 Gas 成本
如果 Gas 成本是可以变化的,那么智能合约就不能依赖于任何特定的 Gas 成本。
任何使用
transfer()
或
send()
的智能合约,都是通过转发固定数量的 Gas 来而产生 2300Gas 成本的硬性依赖。
因此建议停止在代码中使用
transfer()
和
send()
,而改用
call()
。
contract Vulnerable { function withdraw(uint256 amount) external { // This forwards 2300 gas, which may not be enough if the recipient // is a contract and gas costs change. msg.sender.transfer(amount); } } contract Fixed { function withdraw(uint256 amount) external { // This forwards all available gas. Be sure to check the return value! (bool success, ) = msg.sender.call.value(amount)(""); require(success, "Transfer failed."); } }
除了转发固定的 2300Gas 之外,这两个合约是等价的。
关于重入攻击怎么办?
重入攻击 [11],希望是你看到上述代码后的第一反应。引入
transfer()
和
send()
的全部原因是为了解决 The DAO[12] 上臭名昭著的黑客事件的原因。当时的想法是,2300Gas 足够触发一个日志条目,但不足以进行再重入的调用来修改存储状态。
不过请记住,Gas 成本是会变化的,这意味着无论如何这都不是解决再重入攻击的好办法。19 年初,君士坦丁堡分叉被推迟 [13],就是因为 gas 成本的降低,导致以前重入攻击安全的代码不再安全。
如果我们不打算再使用
transfer()
和
send()
,我们就必须用更强大的方式来防止重入。幸运的是,这个问题有很好的解决办法。
检查-生效-交互模式
消除重入性 bug 最简单的方法是使用检查-生效-交互 (checks-effects-interactions)[14]。这是一个典型的重入 bug 的例子:
contract Vulnerable { ... function withdraw() external { uint256 amount = balanceOf[msg.sender]; (bool success, ) = msg.sender.call.value(amount)(""); require(success, "Transfer failed."); balanceOf[msg.sender] = 0; } }
如果
msg.sender
是一个智能合约,它在第 6 行有机会在第 7 行发生之前再次调用
withdraw()
。在那第二次调用中,
balanceOf[msg.sender]
还是原来的金额,所以会再次转账。这可以根据需要重复多次,以耗尽智能合约。
检查-生效-交互模式的想法是确保你所有的交互(外部调用)都发生在最后。上述代码的典型修复方法如下:
1contract Fixed { 2 ... 3 4 function withdraw() external { 5 uint256 amount = balanceOf[msg.sender]; 6 balanceOf[msg.sender] = 0; 7 (bool success, ) = msg.sender.call.value(amount)(""); 8 require(success, "Transfer failed."); 9 } 10}
请注意,在这段代码中,余额在转账之前就被清零了,所以试图对
withdraw()
进行重入调用对攻击者来说没有收益。
使用重入防护
另一种防止重入的方法是明确地检查和拒绝这种调用。下面是一个简单版的重入防护,大家可以看看思路:
1contract Guarded { 2 ... 3 4 bool locked = false; 5 6 function withdraw() external { 7 require(!locked, "Reentrant call detected!"); 8 locked = true; 9 ... 10 locked = false; 11 } 12}
在这段代码中,如果尝试重入调用,第 7 行的
require
将拒绝它,因为
lock
仍然被设置为
true
。
在 OpenZeppelin 的
ReentrancyGuard
[15] 合约中可以找到一个更复杂、更节省 gas 的版本。如果你继承了
ReentrancyGuard
,你只需要用
nonReentrant
来修饰函数,防止重入。
请注意,这个方法只应该用于保护重入, 如果你明确地将其应用于所有正确的函数 。由于需要在储存中保持一个值,它也会增加 Gas 成本。
Vyper 语言有出现这个情况吗?
Vyper 的
send()
函数 [16] 与 Solidity 的
transfer()
一样使用硬编码 Gas ”津贴“,所以也要避免使用。你可以使用
raw_call
[17] 代替。
Vyper 内置了一个
@nonreentrant()
修饰器 [18],其工作原理类似于 OpenZeppelin 的
ReentrancyGuard
。
总结
-
在 Gas 成本不变的假设下,推荐
transfer()
是有道理的。 -
但 Gas 成本不是不变的。智能合约应该有力地应对这一事实。
-
Solidity 的
transfer()
和send()
使用一个硬编码的 Gas 成本。 -
这些方法应避免使用。使用
.call.value(...)("")
代替。 -
这就存在着重入的风险。一定要使用现有的一种强大的方法来防止重入漏洞。
-
Vyper 的
send()
也有同样的问题。
本翻译由 Cell Network[19] 赞助支持。
来源: https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
参考资料
[1]
登链翻译计划 : https://github.com/lbc-team/Pioneer
[2]
翻译小组 : https://learnblockchain.cn/people/412
[3]
Tiny 熊 : https://learnblockchain.cn/people/15
[4]
EIP 1884: https://learnblockchain.cn/docs/eips/eip-1884.html
[5]
伊斯坦布尔硬分叉 : https://learnblockchain.cn/2019/11/21/istanbul-update
[6]
EIP 1884: https://learnblockchain.cn/docs/eips/eip-1884.html
[7]
破坏了一些现有的智能合约 : https://docs.google.com/presentation/d/1IiRYSjwle02zQUmWId06Bss8GrxGyw6nQAiZdCRFEPk/edit
[8]
fallback 函数 : https://learnblockchain.cn/docs/solidity/contracts.html#fallback
[9]
Solidity 的
transfer()
或
send()
方法 :
https://solidity.readthedocs.io/en/v0.5.11/units-and-global-variables.html#members-of-address-types
[10]
动机部分 : https://eips.ethereum.org/EIPS/eip-1884#motivation
[11]
重入攻击 : https://learnblockchain.cn/docs/solidity/security-considerations.html#re-entance
[12]
The DAO: https://learnblockchain.cn/2019/04/07/dao
[13]
君士坦丁堡分叉被推迟 : https://blog.ethereum.org/2019/01/15/security-alert-ethereum-constantinople-postponement/
[14]
检查-生效-交互 (checks-effects-interactions): https://learnblockchain.cn/docs/solidity/security-considerations.html#checks-effects-interactions
[15]
OpenZeppelin 的
ReentrancyGuard
:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol
[16]
Vyper 的
send()
函数 :
https://vyper.readthedocs.io/en/v0.1.0-beta.12/built-in-functions.html#send
[17]
raw_call
:
https://vyper.readthedocs.io/en/v0.1.0-beta.10/built-in-functions.html#raw-call
[18]
@nonreentrant()
修饰器 :
https://vyper.readthedocs.io/en/v0.1.0-beta.12/structure-of-a-contract.html#decorators
[19]
Cell Network: https://www.cellnetwork.io/?utm_souce=learnblockchain