- 译文出自:登链翻译计划[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 的例子:
代码语言:javascript复制 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]
还是原来的金额,所以会再次转账。这可以根据需要重复多次,以耗尽智能合约。
检查-生效-交互模式的想法是确保你所有的交互(外部调用)都发生在最后。上述代码的典型修复方法如下:
代码语言:javascript复制 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()
进行重入调用对攻击者来说没有收益。
使用重入防护
另一种防止重入的方法是明确地检查和拒绝这种调用。下面是一个简单版的重入防护,大家可以看看思路:
代码语言:javascript复制 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