停止使用Solidity的transfer()

Solidity的transfer() 是不安全的。

> * 原文:https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/ 作者: STEVE MARX > * 译文出自:[登链翻译计划](https://github.com/lbc-team/Pioneer) > * 译者:[翻译小组](https://learnblockchain.cn/people/412) > * 校对:[Tiny 熊](https://learnblockchain.cn/people/15) > * 本文永久链接:[learnblockchain.cn/article…](https://learnblockchain.cn/article/2191) 由于[EIP 1884](https://learnblockchain.cn/docs/eips/eip-1884.html)已经在[伊斯坦布尔硬分叉](https://learnblockchain.cn/2019/11/21/istanbul-update)实施,[EIP 1884](https://learnblockchain.cn/docs/eips/eip-1884.html)增加了` SLOAD `操作的Gas成本,因此[*破坏了一些现有的智能合约*](https://docs.google.com/presentation/d/1IiRYSjwle02zQUmWId06Bss8GrxGyw6nQAiZdCRFEPk/edit)。 这些合约将被破坏,因为它们的[fallback 函数](https://learnblockchain.cn/docs/solidity/contracts.html#fallback)以前消耗的Gas不到2300,而现在会消耗更多。 为什么2300 Gas 这么重要? 这是合约的fallback 函数通过[Solidity的`transfer()`或`send()`方法](https://solidity.readthedocs.io/en/v0.5.11/units-and-global-variables.html#members-of-address-types)调用时可使用的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的[动机部分](https://eips.ethereum.org/EIPS/eip-1884#motivation)。 > 操作的价格和资源消耗(CPU时间、内存等)之间的不平衡有几个缺点: > > - 可能被用于攻击,通过用低Gas操作填充区块,导致区块处理时间过长。 > - 价格过低的操作码会歪曲区块Gas限制 ,有时区块完成得很快,但其他Gas使用量相似的区块完成得很慢。 > > 如果操作定价更均衡,我们可以最大限度地提高块Gas限制,并有一个更稳定的处理时间。 `SLOAD`历来价格偏低,EIP 1884纠正了这一问题。 ## 智能合约不能依赖Gas成本 如果Gas成本是可以变化的,那么智能合约就不能依赖于任何特定的Gas成本。 任何使用`transfer()`或`send()`的智能合约,都是通过转发固定数量的Gas来而产生2300Gas成本的硬性依赖。 因此建议停止在代码中使用`transfer()`和`send()`,而改用`call()`。 ```javascript 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之外,这两个合约是等价的。 ## 关于重入攻击怎么办? [重入攻击](https://learnblockchain.cn/docs/solidity/security-considerations.html#re-entance),希望是你看到上述代码后的第一反应。 引入 `transfer() `和 `send() `的全部原因是为了解决[The DAO](https://learnblockchain.cn/2019/04/07/dao)上臭名昭著的黑客事件的原因。 当时的想法是,2300Gas足够触发一个日志条目,但不足以进行再重入的调用来修改存储状态。 不过请记住,Gas成本是会变化的,这意味着无论如何这都不是解决再重入攻击的好办法。 19 年初,[君士坦丁堡分叉被推迟](https://blog.ethereum.org/2019/01/15/security-alert-ethereum-constantinople-postponement/),就是因为gas成本的降低,导致以前重入攻击安全的代码不再安全。 如果我们不打算再使用`transfer()`和`send()`,我们就必须用更强大的方式来防止重入。 幸运的是,这个问题有很好的解决办法。 ### 检查-生效-交互模式 消除重入性bug最简单的方法是使用[检查-生效-交互(checks-effects-interactions)](https://learnblockchain.cn/docs/solidity/security-considerations.html#checks-effects-interactions)。 这是一个典型的重入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`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol)合约中可以找到一个更复杂、更节省gas的版本。 如果你继承了 `ReentrancyGuard`,你只需要用 `nonReentrant `来修饰函数,防止重入。 请注意,这个方法只应该用于保护重入,**如果你明确地将其应用于所有正确的函数**。 由于需要在储存中保持一个值,它也会增加Gas成本。 ## Vyper 语言有出现这个情况吗? [Vyper的`send()`函数](https://vyper.readthedocs.io/en/v0.1.0-beta.12/built-in-functions.html#send)与Solidity的`transfer()`一样使用硬编码Gas ”津贴“,所以也要避免使用。 你可以使用[`raw_call`](https://vyper.readthedocs.io/en/v0.1.0-beta.10/built-in-functions.html#raw-call)代替。 Vyper内置了一个[`@nonreentrant()` 修饰器](https://vyper.readthedocs.io/en/v0.1.0-beta.12/structure-of-a-contract.html#decorators),其工作原理类似于OpenZeppelin的`ReentrancyGuard`。 ## 总结 - 在Gas成本不变的假设下,推荐`transfer()`是有道理的。 - 但Gas成本不是不变的。 智能合约应该有力地应对这一事实。 - Solidity的 `transfer() `和 `send() `使用一个硬编码的Gas 成本。 - 这些方法应避免使用。使用`.call.value(...)("")`代替。 - 这就存在着重入的风险。 一定要使用现有的一种强大的方法来防止重入漏洞。 - Vyper的`send()`也有同样的问题。 ---- 本翻译由 [Cell Network](https://www.cellnetwork.io/?utm_souce=learnblockchain) 赞助支持。

  • 原文: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()

Gas成本可以改变

EVM支持的每个操作码都有相关的Gas成本。 例如,SLOAD,从存储中读取一个字,在EIP 1884中 gas 由 200 修改为 800 。

Gas费用不是随意的。 它们旨在反映组成以太坊的节点上每个操作所消耗的基本资源。

来自EIP的动机部分。

操作的价格和资源消耗(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之外,这两个合约是等价的。

关于重入攻击怎么办?

重入攻击,希望是你看到上述代码后的第一反应。 引入 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 语言有出现这个情况吗?

Vyper的send()函数与Solidity的transfer()一样使用硬编码Gas ”津贴“,所以也要避免使用。 你可以使用raw_call代替。

Vyper内置了一个@nonreentrant() 修饰器,其工作原理类似于OpenZeppelin的ReentrancyGuard

总结

  • 在Gas成本不变的假设下,推荐transfer()是有道理的。
  • 但Gas成本不是不变的。 智能合约应该有力地应对这一事实。
  • Solidity的 transfer()send()使用一个硬编码的Gas 成本。
  • 这些方法应避免使用。使用.call.value(...)("")代替。
  • 这就存在着重入的风险。 一定要使用现有的一种强大的方法来防止重入漏洞。
  • Vyper的send()也有同样的问题。

本翻译由 Cell Network 赞助支持。

区块链技术网。

  • 发表于 2021-02-28 09:45
  • 阅读 ( 1461 )
  • 学分 ( 43 )
  • 分类:Solidity
  • 专栏:全面掌握Solidity智能合约开发

评论