# smart-contract-best-practices **Repository Path**: gg9595/smart-contract-best-practices ## Basic Information - **Project Name**: smart-contract-best-practices - **Description**: A guide to smart contract security best practices - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-03-17 - **Last Updated**: 2021-03-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 以太坊智能合约 —— 最佳安全开发指南 **Notice: this translation was generously provided by a contributor. The maintainers are not able to verify the content. Any issues or PRs to help improve it are welcome.** *本文翻译自:https://github.com/ConsenSys/smart-contract-best-practices 为了使语句表达更加贴切,个别地方未按照原文逐字逐句翻译,如有出入请以原文为准。* **主要章节如下**: - [**Solidity安全贴士**](#solidity-tips) - [**已知的攻击手段**](#known-attacks) - [***竞态***](#race-conditions) - [***可重入***](#reentrancy) - [***交易顺序依赖***](#transaction-ordering-dependence) - [***针对Gas的攻击***](#dos-with-block-gas-limit) - [***整数上溢/整数下溢***](#integer-overflow-and-underflow) - [**软件工程开发技巧**](#eng-techniques) - [**参考文献**](#bibliography) 这篇文档旨在为Solidity开发人员提供一些智能合约的安全准则(**security baseline**)。当然也包括智能合约的**安全开发理念、bug赏金计划指南、文档例程以及工具。** 我们邀请社区对该文档提出修改或增补建议,欢迎各种合并请求(Pull Request)。若有相关的文章或者博客的发表,也清将其加入到[参考文献](#bibliography)中,具体详情请参见我们的[社区贡献指南](CONTRIBUTING.md)。 #### 更多期待内容 我们欢迎并期待社区开发者贡献以下几个方面的内容: - Solidity代码测试(包括代码结构,程序框架 以及 常见软件工程测试) - 智能合约开发经验总结,以及更广泛的基于区块链的开发技巧分享 ## 基本理念 以太坊和其他复杂的区块链项目都处于早期阶段并且有很强的实验性质。因此,随着新的bug和安全漏洞被发现,新的功能不断被开发出来,其面临的安全威胁也是不断变化的。这篇文章对于开发人员编写安全的智能合约来说只是个开始。 开发智能合约需要一个全新的工程思维,它不同于我们以往项目的开发。因为它犯错的代价是巨大的,并且很难像传统软件那样轻易的打上补丁。就像直接给硬件编程或金融服务类软件开发,相比于web开发和移动开发都有更大的挑战。因此,仅仅防范已知的漏洞是不够的,你还需要学习新的开发理念: - **对可能的错误有所准备**。任何有意义的智能合约或多或少都存在错误。因此你的代码必须能够正确的处理出现的bug和漏洞。始终保证以下规则: - 当智能合约出现错误时,停止合约,(“断路开关”) - 管理账户的资金风险(限制(转账)速率、最大(转账)额度) - 有效的途径来进行bug修复和功能提升 - [**谨慎发布智能合约**](#contract-rollout)。 尽量在正式发布智能合约之前发现并修复可能的bug。 - 对智能合约进行彻底的测试,并在任何新的攻击手法被发现后及时的测试(包括已经发布的合约) - 从alpha版本在测试网(testnet)上发布开始便提供[bug赏金计划](#bounties) - 阶段性发布,每个阶段都提供足够的测试 - **保持智能合约的简洁**。复杂会增加出错的风险。 - 确保智能合约逻辑简洁 - 确保合约和函数模块化 - 使用已经被广泛使用的合约或工具(比如,不要自己写一个随机数生成器) - 条件允许的话,清晰明了比性能更重要 - 只在你系统的去中心化部分使用区块链 - **保持更新**。通过下一章节所列出的资源来确保获取到最新的安全进展。 - 在任何新的漏洞被发现时检查你的智能合约 - 尽可能快的将使用到的库或者工具更新到最新 - 使用最新的安全技术 - **清楚区块链的特性**。尽管你先前所拥有的编程经验同样适用于以太坊开发,但这里仍然有些陷阱你需要留意: - 特别小心针对外部合约的调用,因为你可能执行的是一段恶意代码然后更改控制流程 - 清楚你的public function是公开的,意味着可以被恶意调用。(在以太坊上)你的private data也是对他人可见的 - 清楚gas的花费和区块的gas limit ### 基本权衡:简单性与复杂性 在评估一个智能合约的架构和安全性时有很多需要权衡的地方。对任何智能合约的建议是在各个权衡点中找到一个平衡点。 从传统软件工程的角度出发:一个理想的智能合约首先需要模块化,能够重用代码而不是重复编写,并且支持组件升级。从智能合约安全架构的角度出发同样如此,模块化和重用被严格审查检验过的合约是最佳策略,特别是在复杂智能合约系统里。 然而,这里有几个重要的例外,它们从合约安全和传统软件工程两个角度考虑,所得到的重要性排序可能不同。当中每一条,都需要针对智能合约系统的特点找到最优的组合方式来达到平衡。 - 固化 vs 可升级 - 一体化 vs 模块化 - 重复 vs 可重用 #### 固化 vs 可升级 在很多文档或者开发指南中,包括该指南,都会强调延展性比如:可终止,可升级或可更改的特性,不过对于智能合约来说,延展性和安全之间是个*基本权衡*。 延展性会增加程序复杂性和潜在的攻击面。对于那些只在特定的时间段内提供有限的功能的智能合约,简单性比复杂性显得更加高效,比如无管治功能,有限短期内使用的代币发行的智能合约系统(governance-fee,finite-time-frame token-sale contracts)。 #### 一体化 vs 模块化 一个一体化的独立的智能合约把所有的变量和模块都放到一个合约中。尽管只有少数几个大家熟知的智能合约系统真的做到了大体量,但在将数据和流程都放到一个合约中还是享有部分优点--比如,提高代码审核(code review)效率。 和在这里讨论的其他权衡点一样,传统软件开发策略和从合约安全角度出发考虑,两者不同主要在对于简单、短生命周期的智能合约;对于更复杂、长生命周期的智能合约,两者策略理念基本相同。 #### 重复 vs 可重用 从软件工程角度看,智能合约系统希望在合理的情况下最大程度地实现重用。 在Solidity中重用合约代码有很多方法。 使用**你拥有的**以前部署的经过验证的智能合约是实现代码重用的最安全的方式。 在以前所拥有已部署智能合约不可重用时重复还是很需要的。 现在[Live Libs](https://github.com/ConsenSys/live-libs) 和[Zeppelin Solidity](https://github.com/OpenZeppelin/zeppelin-solidity) 正寻求提供安全的智能合约组件使其能够被重用而不需要每次都重新编写。任何合约安全性分析都必须标明重用代码,特别是以前没有建立与目标智能合同系统中处于风险中的资金相称的信任级别的代码。 ## 安全通知 以下这些地方通常会通报在Ethereum或Solidity中新发现的漏洞。安全通告的官方来源是Ethereum Blog,但是一般漏洞都会在其他地方先被披露和讨论。 - [Ethereum Blog](https://blog.ethereum.org/): The official Ethereum blog - [Ethereum Blog - Security only](https://blog.ethereum.org/category/security/): 所有相关博客都带有**Security**标签 - [Ethereum Gitter](https://gitter.im/orgs/ethereum/rooms) 聊天室 - [Solidity](https://gitter.im/ethereum/solidity) - [Go-Ethereum](https://gitter.im/ethereum/go-ethereum) - [CPP-Ethereum](https://gitter.im/ethereum/cpp-ethereum) - [Research](https://gitter.im/ethereum/research) - [Reddit](https://www.reddit.com/r/ethereum) - [Network Stats](https://ethstats.net/) 强烈建议你经常浏览这些网站,尤其是他们提到的可能会影响你的智能合约的漏洞。 另外, 这里列出了以太坊参与安全模块相关的核心开发成员, 浏览 [bibliography](https://github.com/ConsenSys/smart-contract-best-practices#smart-contract-security-bibliography) 获取更多信息。 - **Vitalik Buterin**: [Twitter](https://twitter.com/vitalikbuterin), [Github](https://github.com/vbuterin), [Reddit](https://www.reddit.com/user/vbuterin), [Ethereum Blog](https://blog.ethereum.org/author/vitalik-buterin/) - **Dr. Christian Reitwiessner**: [Twitter](https://twitter.com/ethchris), [Github](https://github.com/chriseth), [Ethereum Blog](https://blog.ethereum.org/author/christian_r/) - **Dr. Gavin Wood**: [Twitter](https://twitter.com/gavofyork), [Blog](http://gavwood.com/), [Github](https://github.com/gavofyork) - **Vlad Zamfir**: [Twitter](https://twitter.com/vladzamfir), [Github](https://github.com/vladzamfir), [Ethereum Blog](https://blog.ethereum.org/author/vlad/) 除了关注核心开发成员,参与到各个区块链安全社区也很重要,因为安全漏洞的披露或研究将通过各方进行。 ## 关于使用Solidity开发的智能合约安全建议 ### 外部调用 #### 尽量避免外部调用 调用不受信任的外部合约可能会引发一系列意外的风险和错误。外部调用可能在其合约和它所依赖的其他合约内执行恶意代码。因此,每一个外部调用都会有潜在的安全威胁,尽可能的从你的智能合约内移除外部调用。当无法完全去除外部调用时,可以使用这一章节其他部分提供的建议来尽量减少风险。 #### 仔细权衡“send()”、“transfer()”、以及“call.value()” 当转账Ether时,需要仔细权衡“someAddress.send()”、“someAddress.transfer()”、和“someAddress.call.value()()”之间的差别。 - `x.transfer(y)`和`if (!x.send(y)) throw;`是等价的。send是transfer的底层实现,建议尽可能直接使用transfer。 - `someAddress.send()`和`someAddress.transfer()` 能保证[可重入](#reentrancy) **安全** 。 尽管这些外部智能合约的函数可以被触发执行,但补贴给外部智能合约的2,300 gas,意味着仅仅只够记录一个event到日志中。 - `someAddress.call.value()()` 将会发送指定数量的Ether并且触发对应代码的执行。被调用的外部智能合约代码将享有所有剩余的gas,通过这种方式转账是很容易有可重入漏洞的,非常 **不安全**。 使用`send()` 或`transfer()` 可以通过制定gas值来预防可重入, 但是这样做可能会导致在和合约调用fallback函数时出现问题,由于gas可能不足,而合约的fallback函数执行至少需要2,300 gas消耗。 一种被称为[*push* 和*pull*](#favor-pull-over-push-payments)的 机制试图来平衡两者, 在 *push* 部分使用`send()` 或`transfer()`,在*pull* 部分使用`call.value()()`。(*译者注:在需要对外未知地址转账Ether时使用`send()` 或`transfer()`,已知明确内部无恶意代码的地址转账Ether使用`call.value()()`) 需要注意的是使用`send()` 或`transfer()` 进行转账并不能保证该智能合约本身重入安全,它仅仅只保证了这次转账操作时重入安全的。 #### 处理外部调用错误 Solidity提供了一系列在raw address上执行操作的底层方法,比如: `address.call()`,`address.callcode()`, `address.delegatecall()`和`address.send`。这些底层方法不会抛出异常(throw),只是会在遇到错误时返回false。另一方面, *contract calls* (比如,`ExternalContract.doSomething()`))会自动传递异常,(比如,`doSomething()`抛出异常,那么`ExternalContract.doSomething()` 同样会进行`throw`) )。 如果你选择使用底层方法,一定要检查返回值来对可能的错误进行处理。 ```sh // bad someAddress.send(55); someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn't check for result someAddress.call.value(100)(bytes4(sha3("deposit()"))); // if deposit throws an exception, the raw call() will only return false and transaction will NOT be reverted // good if(!someAddress.send(55)) { // Some failure code } ExternalContract(someAddress).deposit.value(100); ``` #### 不要假设你知道外部调用的控制流程 无论是使用**raw calls** 或是**contract calls**,如果这个`ExternalContract`是不受信任的都应该假设存在恶意代码。即使`ExternalContract`不包含恶意代码,但它所调用的其他合约代码可能会包含恶意代码。一个具体的危险例子便是恶意代码可能会劫持控制流程导致竞态。(浏览[Race Conditions](https://github.com/ConsenSys/smart-contract-best-practices/#race-conditions)获取更多关于这个问题的讨论) #### 对于外部合约优先使用*pull* 而不是*push* 外部调用可能会有意或无意的失败。为了最小化这些外部调用失败带来的损失,通常好的做法是将外部调用函数与其余代码隔离,最终是由收款发起方负责发起调用该函数。这种做法对付款操作尤为重要,比如让用户自己撤回资产而不是直接发送给他们。(*译者注:事先设置需要付给某一方的资产的值,表明接收方可以从当前账户撤回资金的额度,然后由接收方调用当前合约提现函数完成转账*)。(这种方法同时也避免了造成 [gas limit相关问题](https://github.com/ConsenSys/smart-contract-best-practices/#dos-with-block-gas-limit)。) ```sh // bad contract auction { address highestBidder; uint highestBid; function bid() payable { if (msg.value < highestBid) throw; if (highestBidder != 0) { if (!highestBidder.send(highestBid)) { // if this call consistently fails, no one else can bid throw; } } highestBidder = msg.sender; highestBid = msg.value; } } // good contract auction { address highestBidder; uint highestBid; mapping(address => uint) refunds; function bid() payable external { if (msg.value < highestBid) throw; if (highestBidder != 0) { refunds[highestBidder] += highestBid; // record the refund that this user can claim } highestBidder = msg.sender; highestBid = msg.value; } function withdrawRefund() external { uint refund = refunds[msg.sender]; refunds[msg.sender] = 0; if (!msg.sender.send(refund)) { refunds[msg.sender] = refund; // reverting state because send failed } } } ``` #### 标记不受信任的合约 当你自己的函数调用外部合约时,你的变量、方法、合约接口命名应该表明和他们可能是不安全的。 ```sh // bad Bank.withdraw(100); // Unclear whether trusted or untrusted function makeWithdrawal(uint amount) { // Isn't clear that this function is potentially unsafe Bank.withdraw(amount); } // good UntrustedBank.withdraw(100); // untrusted external call TrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corp function makeUntrustedWithdrawal(uint amount) { UntrustedBank.withdraw(amount); } ``` ### 使用`assert()`强制不变性 当断言条件不满足时将触发断言保护 -- 比如不变的属性发生了变化。举个例子,代币在以太坊上的发行比例,在代币的发行合约里可以通过这种方式得到解决。断言保护经常需要和其他技术组合使用,比如当断言被触发时先挂起合约然后升级。(否则将一直触发断言,你将陷入僵局) 例如: ``` contract Token { mapping(address => uint) public balanceOf; uint public totalSupply; function deposit() public payable { balanceOf[msg.sender] += msg.value; totalSupply += msg.value; assert(address(this).balance >= totalSupply); } } ``` 注意断言保护 **不是** 严格意义的余额检测, 因为智能合约可以不通过`deposit()` 函数被 [强制发送Ether](#ether-forcibly-sent)! ### 正确使用`assert()`和`require()` 在Solidity 0.4.10 中`assert()`和`require()`被加入。`require(condition)`被用来验证用户的输入,如果条件不满足便会抛出异常,应当使用它验证所有用户的输入。 `assert(condition)` 在条件不满足也会抛出异常,但是最好只用于固定变量:内部错误或你的智能合约陷入无效的状态。遵循这些范例,使用分析工具来验证永远不会执行这些无效操作码:意味着代码中不存在任何不变量,并且代码已经正式验证。 ### 小心整数除法的四舍五入 所有整数除数都会四舍五入到最接近的整数。 如果您需要更高精度,请考虑使用乘数,或存储分子和分母。 (将来Solidity会有一个fixed-point类型来让这一切变得容易。) ```sh // bad uint x = 5 / 2; // Result is 2, all integer divison rounds DOWN to the nearest integer // good uint multiplier = 10; uint x = (5 * multiplier) / 2; uint numerator = 5; uint denominator = 2; ``` ### 记住Ether可以被强制发送到账户 谨慎编写用来检查账户余额的不变量。 攻击者可以强制发送wei到任何账户,而且这是不能被阻止的(即使让fallback函数`throw`也不行) 攻击者可以仅仅使用1 wei来创建一个合约,然后调用`selfdestruct(victimAddress)`。在`victimAddress`中没有代码被执行,所以这是不能被阻止的。 ### 不要假设合约创建时余额为零 攻击者可以在合约创建之前向合约的地址发送wei。合约不能假设它的初始状态包含的余额为零。浏览[issue 61](https://github.com/ConsenSys/smart-contract-best-practices/issues/61) 获取更多信息。 ### 记住链上的数据是公开的 许多应用需要提交的数据是私有的,直到某个时间点才能工作。游戏(比如,链上游戏rock-paper-scissors(石头剪刀布))和拍卖机(比如,sealed-bid second-price auctions)是两个典型的例子。如果你的应用存在隐私保护问题,一定要避免过早发布用户信息。 例如: * 在游戏石头剪刀布中,需要参与游戏的双方提交他们“行动计划”的hash值,然后需要双方随后提交他们的行动计划;如果双方的“行动计划”和先前提交的hash值对不上则抛出异常。 * 在拍卖中,要求玩家在初始阶段提交其所出价格的hash值(以及超过其出价的保证金),然后在第二阶段提交他们所出价格的资金。 * 当开发一个依赖随机数生成器的应用时,正确的顺序应当是(1)玩家提交行动计划,(2)生成随机数,(3)玩家支付。产生随机数是一个值得研究的领域;当前最优的解决方案包括比特币区块头(通过http://btcrelay.org验证),hash-commit-reveal方案(比如,一方产生number后,将其散列值提交作为对这个number的“提交”,然后在随后再暴露这个number本身)和 [RANDAO](http://github.com/randao/randao)。 * 如果你正在实现频繁的批量拍卖,那么hash-commit机制也是个不错的选择。 ### 权衡Abstract合约和Interfaces Interfaces和Abstract合约都是用来使智能合约能更好的被定制和重用。Interfaces是在Solidity 0.4.11中被引入的,和Abstract合约很像但是不能定义方法只能申明。Interfaces存在一些限制比如不能够访问storage或者从其他Interfaces那继承,通常这些使Abstract合约更实用。尽管如此,Interfaces在实现智能合约之前的设计智能合约阶段仍然有很大用处。另外,需要注意的是如果一个智能合约从另一个Abstract合约继承而来那么它必须实现所有Abstract合约内的申明并未实现的函数,否则它也会成为一个Abstract合约。 ### 在双方或多方参与的智能合约中,参与者可能会“脱机离线”后不再返回 不要让退款和索赔流程依赖于参与方执行的某个特定动作而没有其他途径来获取资金。比如,在石头剪刀布游戏中,一个常见的错误是在两个玩家提交他们的行动计划之前不要付钱。然而一个恶意玩家可以通过一直不提交它的行动计划来使对方蒙受损失 -- 事实上,如果玩家看到其他玩家泄露的行动计划然后决定他是否会损失(译者注:发现自己输了),那么他完全有理由不再提交他自己的行动计划。这些问题也同样会出现在通道结算。当这些情形出现导致问题后:(1)提供一种规避非参与者和参与者的方式,可能通过设置时间限制,和(2)考虑为参与者提供额外的经济激励,以便在他们应该这样做的所有情况下仍然提交信息。 ### 使Fallback函数尽量简单 [Fallback函数](http://solidity.readthedocs.io/en/latest/contracts.html#fallback-function)在合约执行消息发送没有携带参数(或当没有匹配的函数可供调用)时将会被调用,而且当调用 `.send()` or `.transfer()`时,只会有2,300 gas 用于失败后fallback函数的执行(*译者注:合约收到Ether也会触发fallback函数执行*)。如果你希望能够监听`.send()`或`.transfer()`接收到Ether,则可以在fallback函数中使用event(译者注:让客户端监听相应事件做相应处理)。谨慎编写fallback函数以免gas不够用。 ```sh // bad function() payable { balances[msg.sender] += msg.value; } // good function deposit() payable external { balances[msg.sender] += msg.value; } function() payable { LogDepositReceived(msg.sender); } ``` ### 明确标明函数和状态变量的可见性 明确标明函数和状态变量的可见性。函数可以声明为 `external`,`public`, `internal` 或 `private`。 分清楚它们之间的差异, 例如`external` 可能已够用而不是使用 `public`。对于状态变量,`external`是不可能的。明确标注可见性将使得更容易避免关于谁可以调用该函数或访问变量的错误假设。 ```sh // bad uint x; // the default is private for state variables, but it should be made explicit function buy() { // the default is public // public code } // good uint private y; function buy() external { // only callable externally } function utility() public { // callable externally, as well as internally: changing this code requires thinking about both cases. } function internalAction() internal { // internal code } ``` ### 将程序锁定到特定的编译器版本 智能合约应该应该使用和它们测试时使用最多的编译器相同的版本来部署。锁定编译器版本有助于确保合约不会被用于最新的可能还有bug未被发现的编译器去部署。智能合约也可能会由他人部署,而pragma标明了合约作者希望使用哪个版本的编译器来部署合约。 ```sh // bad pragma solidity ^0.4.4; // good pragma solidity 0.4.4; ``` (*译者注:这当然也会付出兼容性的代价*) ### 小心分母为零 (Solidity < 0.4) 早于0.4版本, 当一个数尝试除以零时,Solidity [返回zero](https://github.com/ethereum/solidity/issues/670) 并没有 `throw` 一个异常。确保你使用的Solidity版本至少为 0.4。 ### 区分函数和事件 为了防止函数和事件(Event)产生混淆,命名一个事件使用大写并加入前缀(我们建议**LOG**)。对于函数, 始终以小写字母开头,构造函数除外。 ```sh // bad event Transfer() {} function transfer() {} // good event LogTransfer() {} function transfer() external {} ``` ### 使用Solidity更新的构造器 更合适的构造器/别名,如`selfdestruct`(旧版本为`suicide`)和`keccak256`(旧版本为`sha3`)。 像`require(msg.sender.send(1 ether))`的模式也可以简化为使用`transfer()`,如`msg.sender.transfer(1 ether)`。 ## 已知的攻击 ### 竞态\* 调用外部合约的主要危险之一是它们可以接管控制流,并对调用函数意料之外的数据进行更改。 这类bug有多种形式,导致DAO崩溃的两个主要错误都是这种错误。 #### 重入 这个版本的bug被注意到是其可以在第一次调用这个函数完成之前被多次重复调用。对这个函数不断的调用可能会造成极大的破坏。 ```sh // INSECURE mapping (address => uint) private userBalances; function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // At this point, the caller's code is executed, and can call withdrawBalance again userBalances[msg.sender] = 0; } ``` (*译者注:使用msg.sender.call.value()())传递给fallback函数可用的gas是当前剩余的所有gas,在这里,假如从你账户执行提现操作的恶意合约的fallback函数内递归调用你的withdrawBalance()便可以从你的账户转走更多的币。*) 可以看到当调msg.sender.call.value()()时,并没有将userBalances[msg.sender] 清零,于是在这之前可以成功递归调用很多次withdrawBalance()函数。 一个非常相像的bug便是出现在针对 DAO 的攻击。 在给出来的例子中,最好的方法是 [ 使用 `send()` 而不是`call.value()()`](https://github.com/ConsenSys/smart-contract-best-practices#send-vs-call-value)。这将避免多余的代码被执行。 然而,如果你没法完全移除外部调用,另一个简单的方法来阻止这个攻击是确保你在完成你所有内部工作之前不要进行外部调用: ```sh mapping (address => uint) private userBalances; function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; userBalances[msg.sender] = 0; if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // The user's balance is already 0, so future invocations won't withdraw anything } ``` 注意如果你有另一个函数也调用了 `withdrawBalance()`, 那么这里潜在的存在上面的攻击,所以你必须认识到任何调用了不受信任的合约代码的合约也是不受信任的。继续浏览下面的相关潜在威胁解决办法的讨论。 #### 跨函数竞态 攻击者也可以使用两个共享状态变量的不同的函数来进行类似攻击。 ```sh // INSECURE mapping (address => uint) private userBalances; function transfer(address to, uint amount) { if (userBalances[msg.sender] >= amount) { userBalances[to] += amount; userBalances[msg.sender] -= amount; } } function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // At this point, the caller's code is executed, and can call transfer() userBalances[msg.sender] = 0; } ``` 着这个例子中,攻击者在他们外部调用`withdrawBalance`函数时调用`transfer()`,如果这个时候`withdrawBalance`还没有执行到`userBalances[msg.sender] = 0;`这里,那么他们的余额就没有被清零,那么他们就能够调用`transfer()`转走代币尽管他们其实已经收到了代币。这个弱点也可以被用到对DAO的攻击。 同样的解决办法也会管用,在执行转账操作之前先清零。也要注意在这个例子中所有函数都是在同一个合约内。然而,如果这些合约共享了状态,同样的bug也可以发生在跨合约调用中。 #### 竞态解决办法中的陷阱 由于竞态既可以发生在跨函数调用,也可以发生在跨合约调用,任何只是避免重入的解决办法都是不够的。 作为替代,我们建议首先应该完成所有内部的工作然后再执行外部调用。这个规则可以避免竞态发生。然而,你不仅应该避免过早调用外部函数而且应该避免调用那些也调用了外部函数的外部函数。例如,下面的这段代码是不安全的: ```sh // INSECURE mapping (address => uint) private userBalances; mapping (address => bool) private claimedBonus; mapping (address => uint) private rewardsForA; function withdraw(address recipient) public { uint amountToWithdraw = userBalances[recipient]; rewardsForA[recipient] = 0; if (!(recipient.call.value(amountToWithdraw)())) { throw; } } function getFirstWithdrawalBonus(address recipient) public { if (claimedBonus[recipient]) { throw; } // Each recipient should only be able to claim the bonus once rewardsForA[recipient] += 100; withdraw(recipient); // At this point, the caller will be able to execute getFirstWithdrawalBonus again. claimedBonus[recipient] = true; } ``` 尽管`getFirstWithdrawalBonus()` 没有直接调用外部合约,但是它调用的`withdraw()` 却会导致竞态的产生。在这里你不应该认为`withdraw()`是受信任的。 ```sh mapping (address => uint) private userBalances; mapping (address => bool) private claimedBonus; mapping (address => uint) private rewardsForA; function untrustedWithdraw(address recipient) public { uint amountToWithdraw = userBalances[recipient]; rewardsForA[recipient] = 0; if (!(recipient.call.value(amountToWithdraw)())) { throw; } } function untrustedGetFirstWithdrawalBonus(address recipient) public { if (claimedBonus[recipient]) { throw; } // Each recipient should only be able to claim the bonus once claimedBonus[recipient] = true; rewardsForA[recipient] += 100; untrustedWithdraw(recipient); // claimedBonus has been set to true, so reentry is impossible } ``` 除了修复bug让重入不可能成功,[不受信任的函数也已经被标记出来](https://github.com/ConsenSys/smart-contract-best-practices#mark-untrusted-contracts) 。同样的情景: `untrustedGetFirstWithdrawalBonus()` 调用`untrustedWithdraw()`, 而后者调用了外部合约,因此在这里`untrustedGetFirstWithdrawalBonus()` 是不安全的。 另一个经常被提及的解决办法是(*译者注:像传统多线程编程中一样*)使用[mutex](https://en.wikipedia.org/wiki/Mutual_exclusion)。它会"lock" 当前状态,只有锁的当前拥有者能够更改当前状态。一个简单的例子如下: ```sh // Note: This is a rudimentary example, and mutexes are particularly useful where there is substantial logic and/or shared state mapping (address => uint) private balances; bool private lockBalances; function deposit() payable public returns (bool) { if (!lockBalances) { lockBalances = true; balances[msg.sender] += msg.value; lockBalances = false; return true; } throw; } function withdraw(uint amount) payable public returns (bool) { if (!lockBalances && amount > 0 && balances[msg.sender] >= amount) { lockBalances = true; if (msg.sender.call(amount)()) { // Normally insecure, but the mutex saves it balances[msg.sender] -= amount; } lockBalances = false; return true; } throw; } ``` 如果用户试图在第一次调用结束前第二次调用 `withdraw()`,将会被锁住。 这看上去很有效果,但当你使用多个合约互相交互时问题变得严峻了。 下面是一段不安全的代码: ``` // INSECURE contract StateHolder { uint private n; address private lockHolder; function getLock() { if (lockHolder != 0) { throw; } lockHolder = msg.sender; } function releaseLock() { lockHolder = 0; } function set(uint newState) { if (msg.sender != lockHolder) { throw; } n = newState; } } ``` 攻击者可以只调用`getLock()`,然后就不再调用 `releaseLock()`。如果他们真这样做,那么这个合约将会被永久锁住,任何接下来的操作都不会发生了。如果你使用mutexs来避免竞态,那么一定要确保没有地方能够打断锁的进程或绝不释放锁。(这里还有一个潜在的威胁,比如死锁和活锁。在你决定使用锁之前最好大量阅读相关文献(*译者注:这是真的,传统的在多线程环境下对锁的使用一直是个容易犯错的地方*))