2019/7/15
README-zh
以太坊智能合约 —— 最佳安全开发指南
https://github.com/ConsenSys/smart-contract-best-practices
chat on gitter
on gitter
chat
主要章节如下:
Solidity安全贴士
已知的攻击手段
Gas
/
软件工程开发技巧
参考文献
这篇文档旨在为Solidity开发人员提供一些智能合约的安全准则(security baseline)。当然也包括智能合约的安
全开发理念、bug赏金计划指南、文档例程以及工具。
我们邀请社区对该文档提出修改或增补建议,欢迎各种合并请求(Pull Request)。若有相关的文章或者博客的发
表,也清将其加入到参考文献中,具体详情请参见我们的社区贡献指南。
更多期待内容
我们欢迎并期待社区开发者贡献以下几个方面的内容: - Solidity代码测试(包括代码结构,程序框架 以及 常
见软件工程测试) - 智能合约开发经验总结,以及更广泛的基于区块链的开发技巧分享
基本理念
以太坊和其他复杂的区块链项目都处于早期阶段并且有很强的实验性质。因此,随着新的bug和安全漏洞被发
现,新的功能不断被开发出来,其面临的安全威胁也是不断变化的。这篇文章对于开发人员编写安全的智能合
约来说只是个开始。
开发智能合约需要一个全新的工程思维,它不同于我们以往项目的开发。因为它犯错的代价是巨大的,并且很
难像传统软件那样轻易的打上补丁。就像直接给硬件编程或金融服务类软件开发,相比于web开发和移动开发
都有更大的挑战。因此,仅仅防范已知的漏洞是不够的,你还需要学习新的开发理念:
对可能的错误有所准备。任何有意义的智能合约或多或少都存在错误。因此你的代码必须能够正确的处理
出现的bug和漏洞。始终保证以下规则:
当智能合约出现错误时,停止合约,(“断路开关”)
管理账户的资金风险(限制(转账)速率、最大(转账)额度)
有效的途径来进行bug修复和功能提升
谨慎发布智能合约。 尽量在正式发布智能合约之前发现并修复可能的bug。
对智能合约进行彻底的测试,并在任何新的攻击手法被发现后及时的测试(包括已经发布的合约)
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
1/21
本
文
翻
译
自
:
。
为
了
使
语
句
表
达
更
加
贴
切
,
个
别
地
方
未
按
照
原
文
逐
字
逐
句
翻
译
,
如
有
出
入
请
以
原
文
为
准
。
竞
态
可
重
入
交
易
顺
序
依
赖
针
对
的
攻
击
整
数
上
溢
整
数
下
溢
2019/7/15
README-zh
从alpha版本在测试网(testnet)上发布开始便提供bug赏金计划
阶段性发布,每个阶段都提供足够的测试
保持智能合约的简洁。复杂会增加出错的风险。
确保智能合约逻辑简洁
确保合约和函数模块化
使用已经被广泛使用的合约或工具(比如,不要自己写一个随机数生成器)
条件允许的话,清晰明了比性能更重要
只在你系统的去中心化部分使用区块链
保持更新。通过下一章节所列出的资源来确保获取到最新的安全进展。
在任何新的漏洞被发现时检查你的智能合约
尽可能快的将使用到的库或者工具更新到最新
使用最新的安全技术
清楚区块链的特性。尽管你先前所拥有的编程经验同样适用于以太坊开发,但这里仍然有些陷阱你需要留
意:
特别小心针对外部合约的调用,因为你可能执行的是一段恶意代码然后更改控制流程
清楚你的public function是公开的,意味着可以被恶意调用。(在以太坊上)你的private data也是对
他人可见的
清楚gas的花费和区块的gas limit
基本权衡:简单性与复杂性
在评估一个智能合约的架构和安全性时有很多需要权衡的地方。对任何智能合约的建议是在各个权衡点中找到
一个平衡点。
从传统软件工程的角度出发:一个理想的智能合约首先需要模块化,能够重用代码而不是重复编写,并且支持
组件升级。从智能合约安全架构的角度出发同样如此,模块化和重用被严格审查检验过的合约是最佳策略,特
别是在复杂智能合约系统里。
然而,这里有几个重要的例外,它们从合约安全和传统软件工程两个角度考虑,所得到的重要性排序可能不
同。当中每一条,都需要针对智能合约系统的特点找到最优的组合方式来达到平衡。
固化 vs 可升级
庞大 vs 模块化
重复 vs 可重用
固化 vs 可升级
在很多文档或者开发指南中,包括该指南,都会强调延展性比如:可终止,可升级或可更改的特性,不过对于
智能合约来说,延展性和安全之间是个
。
延展性会增加程序复杂性和潜在的攻击面。对于那些只在特定的时间段内提供有限的功能的智能合约,简单性
比复杂性显得更加高效,比如无管治功能,有限短期内使用的代币发行的智能合约系统(governance-fee,finite-
time-frame token-sale contracts)。
庞大 vs 模块化
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
2/21
基
本
权
衡
2019/7/15
README-zh
一个庞大的独立的智能合约把所有的变量和模块都放到一个合约中。尽管只有少数几个大家熟知的智能合约系
统真的做到了大体量,但在将数据和流程都放到一个合约中还是享有部分优点--比如,提高代码审核(code
review)效率。
和在这里讨论的其他权衡点一样,传统软件开发策略和从合约安全角度出发考虑,两者不同主要在对于简单、
短生命周期的智能合约;对于更复杂、长生命周期的智能合约,两者策略理念基本相同。
重复 vs 可重用
从软件工程角度看,智能合约系统希望在合理的情况下最大程度地实现重用。 在Solidity中重用合约代码有很
多方法。 使用你拥有的以前部署的经过验证的智能合约是实现代码重用的最安全的方式。
在以前所拥有已部署智能合约不可重用时重复还是很需要的。 现在Live Libs 和Zeppelin Solidity 正寻求提供安
全的智能合约组件使其能够被重用而不需要每次都重新编写。任何合约安全性分析都必须标明重用代码,特别
是以前没有建立与目标智能合同系统中处于风险中的资金相称的信任级别的代码。
安全通知
以下这些地方通常会通报在Ethereum或Solidity中新发现的漏洞。安全通告的官方来源是Ethereum Blog,但是
一般漏洞都会在其他地方先被披露和讨论。
Ethereum Blog: The official Ethereum blog
Ethereum Blog - Security only: 所有相关博客都带有Security标签
Ethereum Gitter 聊天室
Solidity
Go-Ethereum
CPP-Ethereum
Research
Reddit
Network Stats
强烈建议你经常浏览这些网站,尤其是他们提到的可能会影响你的智能合约的漏洞。
另外, 这里列出了以太坊参与安全模块相关的核心开发成员, 浏览 bibliography 获取更多信息。
Vitalik Buterin: Twitter, Github, Reddit, Ethereum Blog
Dr. Christian Reitwiessner: Twitter, Github, Ethereum Blog
Dr. Gavin Wood: Twitter, Blog, Github
Vlad Zamfir: Twitter, Github, Ethereum Blog
除了关注核心开发成员,参与到各个区块链安全社区也很重要,因为安全漏洞的披露或研究将通过各方进行。
关于使用Solidity开发的智能合约安全建议
外部调用
尽量避免外部调用
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
3/21
2019/7/15
README-zh
调用不受信任的外部合约可能会引发一系列意外的风险和错误。外部调用可能在其合约和它所依赖的其他合约
内执行恶意代码。因此,每一个外部调用都会有潜在的安全威胁,尽可能的从你的智能合约内移除外部调用。
当无法完全去除外部调用时,可以使用这一章节其他部分提供的建议来尽量减少风险。
仔细权衡“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() 能保证可重入 安全 。 尽管这些外部智能合约的函数可以
被触发执行,但补贴给外部智能合约的2,300 gas,意味着仅仅只够记录一个event到日志中。
someAddress.call.value()() 将会发送指定数量的Ether并且触发对应代码的执行。被调用的外部智能合约
代码将享有所有剩余的gas,通过这种方式转账是很容易有可重入漏洞的,非常 不安全。
使用send() 或transfer() 可以通过制定gas值来预防可重入, 但是这样做可能会导致在和合约调用fallback函
数时出现问题,由于gas可能不足,而合约的fallback函数执行至少需要2,300 gas消耗。
一种被称为push 和pull的 机制试图来平衡两者, 在 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获取更多关于这个问题的讨论)
对于外部合约优先使用pull 而不是push
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
4/21
2019/7/15
README-zh
外部调用可能会有意或无意的失败。为了最小化这些外部调用失败带来的损失,通常好的做法是将外部调用函
数与其余代码隔离,最终是由收款发起方负责发起调用该函数。这种做法对付款操作尤为重要,比如让用户自
己撤回资产而不是直接发送给他们。(
)。(这种方法同时也避免了造成 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 bi
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
}
}
} ```
标记不受信任的合约
当你自己的函数调用外部合约时,你的变量、方法、合约接口命名应该表明和他们可能是不安全的。
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
5/21
译
者
注
:
事
先
设
置
需
要
付
给
某
一
方
的
资
产
的
值
,
表
明
接
收
方
可
以
从
当
前
账
户
撤
回
资
金
的
额
度
,
然
后
由
接
收
方
调
用
当
前
合
约
提
现
函
数
完
成
转
账
2019/7/15
README-zh
```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(this.balance >= totalSupply);
}
} ``` 注意断言保护 不是 严格意义的余额检测, 因为智能合约可以不通过deposit() 函数被 强制发送Ether!
正确使用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也不行)
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
6/21
2019/7/15
README-zh
攻击者可以仅仅使用1 wei来创建一个合约,然后调用selfdestruct(victimAddress)。在victimAddress中没有
代码被执行,所以这是不能被阻止的。
不要假设合约创建时余额为零
攻击者可以在合约创建之前向合约的地址发送wei。合约不能假设它的初始状态包含的余额为零。浏览issue 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。
如果你正在实现频繁的批量拍卖,那么hash-commit机制也是个不错的选择。
权衡Abstract合约和Interfaces
Interfaces和Abstract合约都是用来使智能合约能更好的被定制和重用。Interfaces是在Solidity 0.4.11中被引入
的,和Abstract合约很像但是不能定义方法只能申明。Interfaces存在一些限制比如不能够访问storage或者从
其他Interfaces那继承,通常这些使Abstract合约更实用。尽管如此,Interfaces在实现智能合约之前的设计智
能合约阶段仍然有很大用处。另外,需要注意的是如果一个智能合约从另一个Abstract合约继承而来那么它必
须实现所有Abstract合约内的申明并未实现的函数,否则它也会成为一个Abstract合约。
在双方或多方参与的智能合约中,参与者可能会“脱机离线”后不再返回
不要让退款和索赔流程依赖于参与方执行的某个特定动作而没有其他途径来获取资金。比如,在石头剪刀布游
戏中,一个常见的错误是在两个玩家提交他们的行动计划之前不要付钱。然而一个恶意玩家可以通过一直不提
交它的行动计划来使对方蒙受损失 -- 事实上,如果玩家看到其他玩家泄露的行动计划然后决定他是否会损失
(译者注:发现自己输了),那么他完全有理由不再提交他自己的行动计划。这些问题也同样会出现在通道结
算。当这些情形出现导致问题后:(1)提供一种规避非参与者和参与者的方式,可能通过设置时间限制,和
(2)考虑为参与者提供额外的经济激励,以便在他们应该这样做的所有情况下仍然提交信息。
使Fallback函数尽量简单
Fallback函数在合约执行消息发送没有携带参数(或当没有匹配的函数可供调用)时将会被调用,而且当调用
.send() or .transfer()时,只会有2,300 gas 用于失败后fallback函数的执行(
Ether
fallback
)。如果你希望能够监听.send()或.transfer()接收到Ether,则可以在fallback函数中
使用event(译者注:让客户端监听相应事件做相应处理)。谨慎编写fallback函数以免gas不够用。
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
7/21
译
者
注
:
合
约
收
到
也
会
触
发
函
数
执
行
2019/7/15
README-zh
```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 并没有 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)。
file:///C:/Users/Dell/AppData/Local/Temp/MarkdownPadPreview.html
8/21
译
者
注
:
这
当
然
也
会
付
出
兼
容
性
的
代
价