长篇干货|以太坊智能合约 —— 最佳安全开发指南(附代码)
2 :、tolak
本文翻译自:。
为了使语句表达更加贴切,个别地方未按照原文逐字逐句翻译,如有出入请以原文为准。
这篇文档旨在为开发人员提供一些智能合约的安全准则( )。当然也包括智能合约的安全开发理念、bug赏金计划指南、文档例程以及工具。对该文档提出修改或增补建议,请点击“阅读原文”。
基本理念
以太坊和其他复杂的区块链项目都处于早期阶段并且有很强的实验性质。因此,随着新的bug和安全漏洞被发现,新的功能不断被开发出来,其面临的安全威胁也是不断变化的。这篇文章对于开发人员编写安全的智能合约来说只是个开始。
开发智能合约需要一个全新的工程思维,它不同于我们以往项目的开发。因为它犯错的代价是巨大的,并且很难像传统软件那样轻易的打上补丁。就像直接给硬件编程或金融服务类软件开发,相比于web开发和移动开发都有更大的挑战。因此,仅仅防范已知的漏洞是不够的,你还需要学习新的开发理念:
对可能的错误有所准备。任何有意义的智能合约或多或少都存在错误。因此你的代码必须能够正确的处理出现的bug和漏洞。始终保证以下规则: - 当智能合约出现错误时,停止合约,(“断路开关”) - 管理账户的资金风险(限制(转账)速率、最大(转账)额度)
谨慎发布智能合约。尽量在正式发布智能合约之前发现并修复可能的bug。 - 对智能合约进行彻底的测试,并在任何新的攻击手法被发现后及时的测试(包括已经发布的合约) - 从alpha版本在测试网()上发布开始便提供bug赏金计划
保持智能合约的简洁。复杂会增加出错的风险。
保持更新。通过下一章节所列出的资源来确保获取到最新的安全进展。
清楚区块链的特性。尽管你先前所拥有的编程经验同样适用于以太坊开发,但这里仍然有些陷阱你需要留意:
基本权衡:简单性与复杂性
在评估一个智能合约的架构和安全性时有很多需要权衡的地方。对任何智能合约的建议是在各个权衡点中找到一个平衡点。
从传统软件工程的角度出发:一个理想的智能合约首先需要模块化,能够重用代码而不是重复编写,并且支持组件升级。从智能合约安全架构的角度出发同样如此imtoken提交代币信息,模块化和重用被严格审查检验过的合约是最佳策略,特别是在复杂智能合约系统里。
然而,这里有几个重要的例外,它们从合约安全和传统软件工程两个角度考虑,所得到的重要性排序可能不同。当中每一条,都需要针对智能合约系统的特点找到最优的组合方式来达到平衡。
固化 vs 可升级
在很多文档或者开发指南中,包括该指南,都会强调延展性比如:可终止,可升级或可更改的特性,不过对于智能合约来说,延展性和安全之间是个基本权衡。
延展性会增加程序复杂性和潜在的攻击面。对于那些只在特定的时间段内提供有限的功能的智能合约,简单性比复杂性显得更加高效,比如无管治功能,有限短期内使用的代币发行的智能合约系统(-fee,-time-frame token-sale )。
庞大 vs 模块化
一个庞大的独立的智能合约把所有的变量和模块都放到一个合约中。尽管只有少数几个大家熟知的智能合约系统真的做到了大体量,但在将数据和流程都放到一个合约中还是享有部分优点--比如,提高代码审核(code )效率。
和在这里讨论的其他权衡点一样,传统软件开发策略和从合约安全角度出发考虑,两者不同主要在对于简单、短生命周期的智能合约;对于更复杂、长生命周期的智能合约,两者策略理念基本相同。
重复 vs 可重用
从软件工程角度看,智能合约系统希望在合理的情况下最大程度地实现重用。 在中重用合约代码有很多方法。 使用你拥有的以前部署的经过验证的智能合约是实现代码重用的最安全的方式。
在以前所拥有已部署智能合约不可重用时重复还是很需要的。 现在Live Libs和 正寻求提供安全的智能合约组件使其能够被重用而不需要每次都重新编写。任何合约安全性分析都必须标明重用代码,特别是以前没有建立与目标智能合同系统中处于风险中的资金相称的信任级别的代码。
安全通知
以下这些地方通常会通报在或中新发现的漏洞。安全通告的官方来源是 Blog,但是一般漏洞都会在其他地方先被披露和讨论。
Blog: The blog
(地址:)
聊天室
(地址:)
(地址:)
Stats(地址:)
强烈建议你经常浏览这些网站,尤其是他们提到的可能会影响你的智能合约的漏洞。
另外, 这里列出了以太坊参与安全模块相关的核心开发成员, 浏览获取更多信息。
(地址:#smart---)
除了关注核心开发成员,参与到各个区块链安全社区也很重要,因为安全漏洞的披露或研究将通过各方进行。
关于使用开发的智能合约安全建议
外部调用
尽量避免外部调用
调用不受信任的外部合约可能会引发一系列意外的风险和错误。外部调用可能在其合约和它所依赖的其他合约内执行恶意代码。因此,每一个外部调用都会有潜在的安全威胁,尽可能的从你的智能合约内移除外部调用。当无法完全去除外部调用时,可以使用这一章节其他部分提供的建议来尽量减少风险。
仔细权衡“send()”、“()”、以及“call.value()”
当转账Ether时,需要仔细权衡
“.send()”、“.()”、和“.call.value()()”之间的差别。
使用send()或()可以通过制定gas值来预防可重入, 但是这样做可能会导致在和合约调用函数时出现问题,由于gas可能不足,而合约的函数执行至少需要2,300 gas消耗。
一种被称为push和pull的机制试图来平衡两者, 在push部分使用send()或(),在pull部分使用call.value()()。
(*译者注:在需要对外未知地址转账Ether时使用send()或(),已知明确内部无恶意代码的地址转账Ether使用call.value()())
需要注意的是使用send()或()进行转账并不能保证该智能合约本身重入安全,它仅仅只保证了这次转账操作时重入安全的。
处理外部调用错误
提供了一系列在raw 上执行操作的底层方法,比如:
.call(),.(),.()和.send。
这些底层方法不会抛出异常(throw),只是会在遇到错误时返回false。
另一方面, calls(比如,
.())会自动传递异常,(比如,
()抛出异常,那么.()同样会进行throw)。
如果你选择使用底层方法,一定要检查返回值来对可能的错误进行处理。
// bad
.send(55);
.call.value(55)(); // this is , as it will all gas and doesn' for
.call.value(100)((sha3("()"))); // if an , the raw call() will only false and will NOT be
// good
if(!.send(55)) {
// Some
()..value(100);
不要假设你知道外部调用的控制流程
无论是使用raw calls或是 calls,如果这个是不受信任的都应该假设存在恶意代码。即使不包含恶意代码,但它所调用的其他合约代码可能会包含恶意代码。一个具体的危险例子便是恶意代码可能会劫持控制流程导致竞态。
(浏览Race 获取更多关于这个问题的讨论,
地址:#race-)
对于外部合约优先使用pull而不是push
外部调用可能会有意或无意的失败。为了最小化这些外部调用失败带来的损失imToken,通常好的做法是将外部调用函数与其余代码隔离,最终是由收款发起方负责发起调用该函数。这种做法对付款操作尤为重要,比如让用户自己撤回资产而不是直接发送给他们。(译者注:事先设置需要付给某一方的资产的值,表明接收方可以从当前账户撤回资金的额度,然后由接收方调用当前合约提现函数完成转账)。
(这种方法同时也避免了造成gas limit相关问题。
地址:#dos-with-block-gas-limit)
// bad
{
;
uint ;
bid() {
if (msg.value uint) ;
bid() {
if (msg.value uint)
;
() {
uint =
[msg.];
if (!(msg..call.value()())) { throw; } // At this point, the 's code is , and can call again
[msg.] = 0;
(译者注:使用msg..call.value()())传递给函数可用的气是当前剩余的所有气,在这里,假如从你账户执行提现操作的恶意合约的函数内递归调用你的()便可以从你的账户转走更多的币。)
可以看到当调msg..call.value()()时,并没有将[msg.] 清零,于是在这之前可以成功递归调用很多次()函数。 一个非常相像的bug便是出现在针对 DAO 的攻击。
在给出来的例子中,最好的方法是:使用send()而不是call.value()()。这将避免多余的代码被执行。
然而,如果你没法完全移除外部调用,另一个简单的方法来阻止这个攻击是确保你在完成你所有内部工作之前不要进行外部调用:
( => uint) ;
nce() {
uint = [msg.];
[msg.] = 0;
if (!(msg..call.value()())) { throw; } // The user's is 0, so won't
注意如果你有另一个函数也调用了(), 那么这里潜在的存在上面的攻击,所以你必须认识到任何调用了不受信任的合约代码的合约也是不受信任的。继续浏览下面的相关潜在威胁解决办法的讨论。
跨函数竞态
攻击者也可以使用两个共享状态变量的不同的函数来进行类似攻击。
//
( => uint) ;
( to, uint ) {
if ([msg.] >= ) {
[to] += ;
[msg.] -= ;
() {
uint = [msg.];
if (!(msg..call.value()())) { throw; } // At this point, the 's code is , and can call () [msg.] = 0;
这个例子中,攻击者在他们外部调用函数时调用(),如果这个时候还没有执行到[msg.] = 0;这里,那么他们的余额就没有被清零,那么他们就能够调用()转走代币尽管他们其实已经收到了代币。这个弱点也可以被用到对DAO的攻击。
同样的解决办法也会管用,在执行转账操作之前先清零。也要注意在这个例子中所有函数都是在同一个合约内。然而,如果这些合约共享了状态,同样的bug也可以发生在跨合约调用中。
竞态解决办法中的陷阱
由于竞态既可以发生在跨函数调用,也可以发生在跨合约调用,任何只是避免重入的解决办法都是不够的。
作为替代,我们建议首先应该完成所有内部的工作然后再执行外部调用。这个规则可以避免竞态发生。然而,你不仅应该避免过早调用外部函数而且应该避免调用那些也调用了外部函数的外部函数。例如,下面的这段代码是不安全的:
//
( => uint) ;
( => bool) ;
( => uint) ;
( ) {
uint = [];
[] = 0;
if (!(.call.value()())) { throw;}
nus( ) {
if ([]) { throw; } // Each only be able to claim the bonus once
[] += 100;
(); // At this point, the will be able to nus again.
[] = true;
尽管nus()没有直接调用外部合约,但是它调用的()却会导致竞态的产生。在这里你不应该认为()是受信任的。
( => uint) ;
( => bool) ;
( => uint) ;
( ) {
uint = [];
[] = 0;
if (!(.call.value()())) { throw; }
( ) {
if ([]) { throw; } // Each only be able to claim the bonus once
[] = true;
[] += 100;
(); // has been set to true, so is
除了修复bug让重入不可能成功,不受信任的函数也已经被标记出来。
同样的情景:()调用(), 而后者调用了外部合约,因此在这里()是不安全的。
另一个经常被提及的解决办法是(译者注:像传统多线程编程中一样)使用mutex。它会"lock" 当前状态,只有锁的当前拥有者能够更改当前状态。一个简单的例子如下:
// Note: This is a , and are where there is logic and/or state
( => uint) ;
bool ;
() (bool) {
if (!) {
= true;
[msg.] += msg.value;
= false;
true;
throw;
(uint ) (bool) {
if (! && > 0 && [msg.] >= ) {
= true;
if (msg..call()()) { // , but the mutex saves it
[msg.] -= ;
= false;
true;
throw;
如果用户试图在第一次调用结束前第二次调用(),将会被锁住。 这看上去很有效果,但当你使用多个合约互相交互时问题变得严峻了。 下面是一段不安全的代码:
//
{
uint n;
;
() {
if ( != 0) { throw; }
= msg.;
() {
= 0;
set(uint ) {
if (msg. != ) { throw; }
n = ;
攻击者可以只调用(),然后就不再调用()。如果他们真这样做,那么这个合约将会被永久锁住,任何接下来的操作都不会发生了。如果你使用来避免竞态,那么一定要确保没有地方能够打断锁的进程或绝不释放锁。(这里还有一个潜在的威胁,比如死锁和活锁。在你决定使用锁之前最好大量阅读相关文献(译者注:这是真的,传统的在多线程环境下对锁的使用一直是个容易犯错的地方))
* 有些人可能会发反对使用该术语竞态,因为以太坊并没有真正意思上实现并行执行。然而在逻辑上依然存在对资源的竞争,同样的陷阱和潜在的解决方案。
交易顺序依赖(TOD) / 前面的先运行
以上是涉及攻击者在单个交易内执行恶意代码产生竞态的示例。接下来演示在区块链本身运作原理导致的竞态:(同一个block内的)交易顺序很容易受到操纵。
由于交易在短暂的时间内会先存放到中,所以在矿工将其打包进block之前,是可以知道会发生什么动作的。这对于一个去中心化的市场来说是麻烦的,因为可以查看到代币的交易信息,并且可以在它被打包进block之前改变交易顺序。避免这一点很困难,因为它归结为具体的合同本身。例如,在市场上,最好实施批量拍卖(这也可以防止高频交易问题)。 另一种使用预提交方案的方法(“我稍后会提供详细信息”)。
时间戳依赖
请注意,块的时间戳可以由矿工操纵,并且应考虑时间戳的所有直接和间接使用。区块数量和平均出块时间可用于估计时间,但这不是区块时间在未来可能改变(例如期望的更改)的证明。
uint = now + 1;
if (now % 2 == 0) { // the now can be by the miner
if (( - 100) % 2 == 0) { // can be by the miner
整数上溢和下溢
这里大概有20关于上溢和下溢的例子。
(#-)
考虑如下这个简单的转账操作:
( => ) ;
//
( _to, ) {
/* Check if has */
if ([msg.]
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。