Solidity的transfer() 是不安全的。
- 原文:https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/ 作者: STEVE MARX
- 译文出自:登链翻译计划
- 译者:翻译小组
- 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
由于EIP 1884已经在伊斯坦布尔硬分叉实施,EIP 1884增加了SLOAD
操作的Gas成本,因此破坏了一些现有的智能合约。
这些合约将被破坏,因为它们的fallback 函数以前消耗的Gas不到2300,而现在会消耗更多。 为什么2300 Gas 这么重要? 这是合约的fallback 函数通过Solidity的transfer()
或send()
方法调用时可使用的Gas 量。
刚才是简化的描述, 2300 是 Gas ”津贴“,如果是非零的以太币量转账,则 Gas ”津贴“ 明确传递给
CALL
。 Solidity的transfer()
将Gas参数设置为0,如果以太币的转账量为非零。 在加上gas”津贴“后,一共是2300 。 如果是零以太币转账,Solidity明确地将Gas参数设置为2300,因此在两种情况下都会是 2300 Gas。
自推出以来,transfer()
通常被安全界推荐,因为它有助于防范重入攻击。 在Gas成本不会改变的假设下,这一指导意见是有意义的,但事实证明这一假设是不正确的。 我们现在建议避免使用transfer()
和send()
。
EVM支持的每个操作码都有相关的Gas成本。 例如,SLOAD
,从存储中读取一个字,在EIP 1884中 gas 由 200 修改为 800 。
Gas费用不是随意的。 它们旨在反映组成以太坊的节点上每个操作所消耗的基本资源。
来自EIP的动机部分。
操作的价格和资源消耗(CPU时间、内存等)之间的不平衡有几个缺点:
- 可能被用于攻击,通过用低Gas操作填充区块,导致区块处理时间过长。
- 价格过低的操作码会歪曲区块Gas限制 ,有时区块完成得很快,但其他Gas使用量相似的区块完成得很慢。
如果操作定价更均衡,我们可以最大限度地提高块Gas限制,并有一个更稳定的处理时间。
SLOAD
历来价格偏低,EIP 1884纠正了这一问题。
如果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之外,这两个合约是等价的。
重入攻击,希望是你看到上述代码后的第一反应。 引入 transfer()
和 send()
的全部原因是为了解决The DAO上臭名昭著的黑客事件的原因。 当时的想法是,2300Gas足够触发一个日志条目,但不足以进行再重入的调用来修改存储状态。
不过请记住,Gas成本是会变化的,这意味着无论如何这都不是解决再重入攻击的好办法。 19 年初,君士坦丁堡分叉被推迟,就是因为gas成本的降低,导致以前重入攻击安全的代码不再安全。
如果我们不打算再使用transfer()
和send()
,我们就必须用更强大的方式来防止重入。 幸运的是,这个问题有很好的解决办法。
消除重入性bug最简单的方法是使用检查-生效-交互(checks-effects-interactions)。 这是一个典型的重入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
合约中可以找到一个更复杂、更节省gas的版本。 如果你继承了 ReentrancyGuard
,你只需要用 nonReentrant
来修饰函数,防止重入。
请注意,这个方法只应该用于保护重入,如果你明确地将其应用于所有正确的函数。 由于需要在储存中保持一个值,它也会增加Gas成本。
Vyper的send()
函数与Solidity的transfer()
一样使用硬编码Gas ”津贴“,所以也要避免使用。 你可以使用raw_call
代替。
Vyper内置了一个@nonreentrant()
修饰器,其工作原理类似于OpenZeppelin的ReentrancyGuard
。
transfer()
是有道理的。transfer()
和 send()
使用一个硬编码的Gas 成本。.call.value(...)("")
代替。send()
也有同样的问题。本翻译由 Cell Network 赞助支持。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!