长篇干货|以太坊智能合约 —— 最佳安全开发指南(附代码)

image

这篇文档旨在为Solidity开发人员提供一些智能合约的安全准则(security baseline)。固然也**包括智能合约的安全开发理念、bug赏金计划指南、文档例程以及工具。**对该文档提出修改或增补建议,请点击“阅读原文”。git

1

基本理念

以太坊和其余复杂的区块链项目都处于早期阶段而且有很强的实验性质。所以,随着新的bug和安全漏洞被发现,新的功能不断被开发出来,其面临的安全威胁也是不断变化的。这篇文章对于开发人员编写安全的智能合约来讲只是个开始。程序员

开发智能合约须要一个全新的工程思惟,它不一样于咱们以往项目的开发。由于它犯错的代价是巨大的,而且很难像传统软件那样轻易的打上补丁。就像直接给硬件编程或金融服务类软件开发,相比于web开发和移动开发都有更大的挑战。所以,仅仅防范已知的漏洞是不够的,你还须要学习新的开发理念:github

**对可能的错误有所准备。**任何有意义的智能合约或多或少都存在错误。所以你的代码必须可以正确的处理出现的bug和漏洞。始终保证如下规则:  - 当智能合约出现错误时,中止合约,(“断路开关”)  - 管理帐户的资金风险(限制(转帐)速率、最大(转帐)额度)web

  • 有效的途径来进行bug修复和功能提高

**谨慎发布智能合约。**尽可能在正式发布智能合约以前发现并修复可能的bug。  - 对智能合约进行完全的测试,并在任何新的攻击手法被发现后及时的测试(包括已经发布的合约)  - 从alpha版本在测试网(testnet)上发布开始便提供bug赏金计划编程

  • 阶段性发布,每一个阶段都提供足够的测试

**保持智能合约的简洁。**复杂会增长出错的风险。数组

  • 确保智能合约逻辑简洁浏览器

  • 确保合约和函数模块化安全

  • 使用已经被普遍使用的合约或工具(好比,不要本身写一个随机数生成器)多线程

  • 条件容许的话,清晰明了比性能更重要架构

  • 只在你系统的去中心化部分使用区块链

**保持更新。**经过下一章节所列出的资源来确保获取到最新的安全进展。

  • 在任何新的漏洞被发现时检查你的智能合约

  • 尽量快的将使用到的库或者工具更新到最新

  • 使用最新的安全技术

**清楚区块链的特性。**尽管你先前所拥有的编程经验一样适用于以太坊开发,但这里仍然有些陷阱你须要留意:

  • 特别当心针对外部合约的调用,由于你可能执行的是一段恶意代码而后更改控制流程

  • 清楚你的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 和Zeppelin Solidity 正寻求提供安全的智能合约组件使其可以被重用而不须要每次都从新编写。任何合约安全性分析都必须标明重用代码,特别是之前没有创建与目标智能合同系统中处于风险中的资金相称的信任级别的代码。

2

安全通知

如下这些地方一般会通报在Ethereum或Solidity中新发现的漏洞。安全通告的官方来源是Ethereum Blog,可是通常漏洞都会在其余地方先被披露和讨论。

Ethereum Blog: The official Ethereum blog

(地址:https://blog.ethereum.org/)

  • Ethereum Blog - Security only: 全部相关博客都带有Security标签

    (https://blog.ethereum.org/category/security/)

Ethereum Gitter 聊天室

(地址:https://gitter.im/ethereum/home)

  • 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, Github, Reddit, Ethereum Blog

  • Dr. Christian Reitwiessner: Twitter, Github, Ethereum Blog

  • Dr. Gavin Wood: Twitter, Blog, Github

  • Vlad Zamfir: Twitter, Github, Ethereum Blog

除了关注核心开发成员,参与到各个区块链安全社区也很重要,由于安全漏洞的披露或研究将经过各方进行。

3

关于使用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() 能保证可重入 安全 。尽管这些外部智能合约的函数能够被触发执行,但补贴给外部智能合约的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 )。

若是你选择使用底层方法,必定要检查返回值来对可能的错误进行处理。

// bad someAddress.send(55);

someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn'tcheck 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 failurecode

}

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)

// 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        }    } }

标记不受信任的合约

当你本身的函数调用外部合约时,你的变量、方法、合约接口命名应该代表和他们多是不安全的。

// 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类型来让这一切变得容易。)

// 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。

  • 若是你正在实现频繁的批量拍卖,那么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不够用。

// 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是不可能的。明确标注可见性将使得更容易避免关于谁能够调用该函数或访问变量的错误假设。

// bad uint x; // the default is private for state variables, but it should be made explicit

functionbuy() { // 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标明了合约做者但愿使用哪一个版本的编译器来部署合约。

// 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)。对于函数, 始终以小写字母开头,构造函数除外。

// 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)

4

已知的攻击

竞态*

调用外部契约的主要危险之一是它们能够接管控制流,并对调用函数意料以外的数据进行更改。 这类bug有多种形式,致使DAO崩溃的两个主要错误都是这种错误。

重入

这个版本的bug被注意到是其能够在第一次调用这个函数完成以前被屡次重复调用。对这个函数不断的调用可能会形成极大的破坏。

// 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函数可用的气是当前剩余的全部气,在这里,假如从你帐户执行提现操做的恶意合约的fallback函数内递归调用你的withdrawBalance()即可以从你的帐户转走更多的币。)

能够看到当调msg.sender.call.value()()时,并无将userBalances[msg.sender] 清零,因而在这以前能够成功递归调用不少次withdrawBalance()函数。 一个很是相像的bug即是出如今针对 DAO 的攻击。

在给出来的例子中,最好的方法是:使用 send() 而不是call.value()()。这将避免多余的代码被执行。

然而,若是你无法彻底移除外部调用,另外一个简单的方法来阻止这个攻击是确保你在完成你全部内部工做以前不要进行外部调用:

mapping (address => uint) private userBalances;

functionwithdrawBalance() 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(), 那么这里潜在的存在上面的攻击,因此你必须认识到任何调用了不受信任的合约代码的合约也是不受信任的。继续浏览下面的相关潜在威胁解决办法的讨论。

跨函数竞态

攻击者也可使用两个共享状态变量的不一样的函数来进行相似攻击。

// 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也能够发生在跨合约调用中。

竞态解决办法中的陷阱

因为竞态既能够发生在跨函数调用,也能够发生在跨合约调用,任何只是避免重入的解决办法都是不够的。

做为替代,咱们建议首先应该完成全部内部的工做而后再执行外部调用。这个规则能够避免竞态发生。然而,你不只应该避免过早调用外部函数并且应该避免调用那些也调用了外部函数的外部函数。例如,下面的这段代码是不安全的:

// INSECURE mapping (address => uint) private userBalances;

mapping (address => bool) private claimedBonus;

mapping (address => uint) private rewardsForA;

functionwithdraw(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()是受信任的。

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让重入不可能成功,不受信任的函数也已经被标记出来 。

一样的情景:untrustedGetFirstWithdrawalBonus()调用untrustedWithdraw(), 然后者调用了外部合约,所以在这里untrustedGetFirstWithdrawalBonus() 是不安全的。

另外一个常常被说起的解决办法是(译者注:像传统多线程编程中同样)使用mutex。它会"lock" 当前状态,只有锁的当前拥有者可以更改当前状态。一个简单的例子以下:

// 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来避免竞态,那么必定要确保没有地方可以打断锁的进程或毫不释放锁。(这里还有一个潜在的威胁,好比死锁和活锁。在你决定使用锁以前最好大量阅读相关文献*(译者注:这是真的,传统的在多线程环境下对锁的使用一直是个容易犯错的地方))*

  • 有些人可能会发反对使用该术语 竞态,由于以太坊并无真正意思上实现并行执行。然而在逻辑上依然存在对资源的竞争,一样的陷阱和潜在的解决方案。

交易顺序依赖(TOD) / 前面的先运行

以上是涉及攻击者在单个交易内执行恶意代码产生竞态的示例。接下来演示在区块链自己运做原理致使的竞态:(同一个block内的)交易顺序很容易受到操纵。

因为交易在短暂的时间内会先存放到mempool中,因此在矿工将其打包进block以前,是能够知道会发生什么动做的。这对于一个去中心化的市场来讲是麻烦的,由于能够查看到代币的交易信息,而且能够在它被打包进block以前改变交易顺序。避免这一点很困难,由于它归结为具体的合同自己。例如,在市场上,最好实施批量拍卖(这也能够防止高频交易问题)。 另外一种使用预提交方案的方法(“我稍后会提供详细信息”)。

时间戳依赖

请注意,块的时间戳能够由矿工操纵,而且应考虑时间戳的全部直接和间接使用。 区块数量平均出块时间可用于估计时间,但这不是区块时间在将来可能改变(例如Casper指望的更改)的证实。

uint someVariable = now + 1;

if (now % 2 == 0) { // the now can be manipulated by the miner

}

if ((someVariable - 100) % 2 == 0) { // someVariable can be manipulated by the miner

}

整数上溢和下溢

这里大概有 20关于上溢和下溢的例子。

(https://github.com/ethereum/solidity/issues/796#issuecomment-253578925)

考虑以下这个简单的转帐操做:

mapping (address => uint256) public balanceOf;

// INSECURE

function transfer(address _to, uint256 _value) {    /* Check if sender has balance */

if (balanceOf[msg.sender] < _value)        throw;    /* Add and subtract new balances */    balanceOf[msg.sender] -= _value;    balanceOf[_to] += _value;

}

// SECURE

function transfer(address _to, uint256 _value) {    /* Check if sender has balance and for overflows */     if (balanceOf[msg.sender] < _value || balanceOf[_to] + _value < balanceOf[_to])        throw;

/* Add and subtract new balances */    balanceOf[msg.sender] -= _value;    balanceOf[_to] += _value;

}

若是余额到达uint的最大值(2^256),便又会变为0。应当检查这里。溢出是否与之相关取决于具体的实施方式。想一想uint值是否有机会变得这么大或和谁会改变它的值。若是任何用户都有权利更改uint的值,那么它将更容易受到攻击。若是只有管理员可以改变它的值,那么它多是安全的,由于没有别的办法能够跨越这个限制。

对于下溢一样的道理。若是一个uint别改变后小于0,那么将会致使它下溢而且被设置成为最大值(2^256)。

对于较小数字的类型好比uint八、uint1六、uint24等也要当心:他们更加容易达到最大值。

经过(Unexpected) Throw发动DoS

考虑以下简单的智能合约:

// INSECURE contract Auction {    address currentLeader;    uint highestBid;

function bid() payable {

if (msg.value <= highestBid) { throw; }

if (!currentLeader.send(highestBid)) { throw; } // Refund the old leader, and throw if it fails

currentLeader = msg.sender;        highestBid = msg.value;    } }

当有更高竞价时,它将试图退款给曾经最高竞价人,若是退款失败则会抛出异常。这意味着,恶意投标人能够成为当前最高竞价人,同时确保对其地址的任何退款始终失败。这样就能够阻止任何人调用“bid()”函数,使本身永远保持领先。建议向以前所说的那样创建**基于pull的支付系统 **。

另外一个例子是合约可能经过数组迭代来向用户支付(例如,众筹合约中的支持者)时。 一般要确保每次付款都成功。 若是没有,应该抛出异常。 问题是,若是其中一个支付失败,您将恢复整个支付系统,这意味着该循环将永远不会完成。 由于一个地址没有转帐成功致使其余人都没获得报酬。

address[] private refundAddresses;

mapping (address => uint) public refunds;

// bad

function refundAll() public {    for(uint x; x < refundAddresses.length; x++) { // arbitrary length iteration based on how many addresses participated        if(refundAddresses[x].send(refunds[refundAddresses[x]])) {            throw; // doubly bad, now a single failure on send will hold up all funds        }    } }

再一次强调,一样的解决办法: 优先使用pull 而不是push支付系统。

经过区块Gas Limit发动DoS

在先前的例子中你可能已经注意到另外一个问题:一次性向全部人转帐,极可能会致使达到以太坊区块gas limit的上限。以太坊规定了每个区块所能花费的gas limit,若是超过你的交易便会失败。

即便没有故意的攻击,这也可能致使问题。然而,最为糟糕的是若是gas的花费被攻击者操控。在先前的例子中,若是攻击者增长一部分收款名单,并设置每个收款地址都接收少许的退款。这样一来,更多的gas将会被花费从而致使达到区块gas limit的上限,整个转帐的操做也会以失败了结。

又一次证实了 优先使用pull 而不是push支付系统

若是你实在必须经过遍历一个变长数组来进行转帐,最好估计完成它们大概须要多少个区块以及多少笔交易。而后你还必须可以追踪获得当前进行到哪以便当操做失败时从那里开始恢复,举个例子:

struct Payee {    address addr;    uint256 value;

} Payee payees[];

uint256 nextPayeeIndex;

function payOut() {    uint256 i = nextPayeeIndex;    while (i < payees.length && msg.gas > 200000) {      payees[i].addr.send(payees[i].value);      i++;    }    nextPayeeIndex = i;

}

如上所示,你必须确保在下一次执行payOut()以前另外一些正在执行的交易不会发生任何错误。若是必须,请使用上面这种方式来处理。

Call Depth攻击

因为EIP 150 进行的硬分叉,Call Depth攻击已经没法实施* (因为以太坊限制了Call Depth最大为1024,确保了在达到最大深度以前gas都能被正确使用)

5

软件工程开发技巧

正如咱们先前在基本理念章节所讨论的那样,避免本身遭受已知的攻击是不够的。因为在链上遭受攻击损失是巨大的,所以你还必须改变你编写软件的方式来抵御各类攻击。

咱们倡导“时刻准备失败",提早知道你的代码是否安全是不可能的。然而,咱们能够容许合约以可预知的方式失败,而后最小化失败带来的损失。本章将带你了解如何为可预知的失败作准备。

注意:当你向你的系统添加新的组件时老是伴随着风险的。一个不良设计自己会成为漏洞-一些精心设计的组件在交互过程当中一样会出现漏洞。仔细考虑你在合约里使用的每一项技术,以及如何将它们整合共同建立一个稳定可靠的系统。

升级有问题的合约

若是代码中发现了错误或者须要对某些部分作改进都须要更改代码。在以太坊上发现一个错误却没有办法处理他们是太多意义的。

关于如何在以太坊上设计一个合约升级系统是一个正处于积极研究的领域,在这篇文章当中咱们无法覆盖全部复杂的领域。然而,这里有两个通用的基本方法。最简单的是专门设计一个注册合约,在注册合约中保存最新版合约的地址。对于合约使用者来讲更能实现无缝衔接的方法是设计一个合约,使用它转发调用请求和数据到最新版的合约。

不管采用何种技术,组件之间都要进行模块化和良好的分离,由此代码的更改才不会破坏原有的功能,形成孤儿数据,或者带来巨大的成本。 尤为是将复杂的逻辑与数据存储分开,这样你在使用更改后的功能时没必要从新建立全部数据。

当须要多方参与决定升级代码的方式也是相当重要的。根据你的合约,升级代码可能会须要经过单个或多个受信任方参与投票决定。若是这个过程会持续很长时间,你就必需要考虑是否要换成一种更加高效的方式以防止遭受到攻击,例如紧急中止或断路器

Example 1:使用注册合约存储合约的最新版本

在这个例子中,调用没有被转发,所以用户必须每次在交互以前都先获取最新的合约地址。

contract SomeRegister {    address backendContract;    address[] previousBackends;    address owner;

function SomeRegister() {        owner = msg.sender;    }

modifier onlyOwner() {

if (msg.sender != owner) {            throw;        }        _;    }

function changeBackend(address newBackend) public

onlyOwner()    returns (bool)    {        if(newBackend != backendContract)

previousBackends.push(backendContract);            backendContract = newBackend;            return true;        }

return false;    } }

这种方法有两个主要的缺点:

  • 用户必须始终查找当前合约地址,不然任何未执行此操做的人均可能会使用旧版本的合约

  • 在你替换了合约后你须要仔细考虑如何处理原合约中的数据

另一种方法是设计一个用来转发调用请求和数据到最新版的合约:

Example 2:使用DELEGATECALL** 转发数据和调用**

contract Relay {    address public currentVersion;    address public owner;

modifier onlyOwner() {        if (msg.sender != owner) {            throw;        }        _;    }

function Relay(address initAddr) {        currentVersion = initAddr;        owner = msg.sender; // this owner may be another contract with multisig, not a single contract owner    }

function changeContract(address newVersion) public    onlyOwner()    {        currentVersion = newVersion;    }

function() {        if(!currentVersion.delegatecall(msg.data)) throw;    } }

这种方法避免了先前的问题,但也有本身的问题。它使得你必须在合约里当心的存储数据。若是新的合约和先前的合约有不一样的存储层,你的数据可能会被破坏。另外,这个例子中的模式无法从函数里返回值,只负责转发它们,由此限制了它的适用性。(这里有一个更复杂的实现 想经过内联汇编和返回大小的注册表来解决这个问题)

不管你的方法如何,重要的是要有一些方法来升级你的合约,不然当被发现不可避免的错误时合约将无法使用。

断路器(暂停合约功能)

因为断路器在知足必定条件时将会中止执行,若是发现错误时可使用断路器。例如,若是发现错误,大多数操做可能会在合约中被挂起,这是惟一的操做就是撤销。你能够受权给任何你受信任的一方,提供给他们触发断路器的能力,或者设计一个在知足某些条件时自动触发某个断路器的程序规则。

例如:

bool private stopped = false; address private owner;

modifier isAdmin() {    if(msg.sender != owner) {        throw;    }    _; }

function toggleContractActive() isAdmin public {    // You can add an additional modifier that restricts stopping a contract to be based on another action, such as a vote of users    stopped = !stopped; }

modifier stopInEmergency { if (!stopped) _; } modifier onlyInEmergency { if (stopped) _; }

function deposit() stopInEmergency public {    // some code }

function withdraw() onlyInEmergency public {    // some code }

速度碰撞(延迟合约动做)

速度碰撞使动做变慢,因此若是发生了恶意操做便有时间恢复。例如,The DAO 从发起分割DAO请求到真正执行动做须要27天。这样保证了资金在此期间被锁定在合约里,增长了系统的可恢复性。在DAO攻击事件中,虽然在速度碰撞给定的时间段内没有有效的措施能够采起,但结合咱们其余的技术,它们是很是有效的。

例如:

struct RequestedWithdrawal {    uint amount;    uint time; }

mapping (address => uint) private balances; mapping (address => RequestedWithdrawal) private requestedWithdrawals; uint constant withdrawalWaitPeriod = 28 days; // 4 weeks

function requestWithdrawal() public {    if (balances[msg.sender] > 0) {        uint amountToWithdraw = balances[msg.sender];        balances[msg.sender] = 0; // for simplicity, we withdraw everything;        // presumably, the deposit function prevents new deposits when withdrawals are in progress

requestedWithdrawals[msg.sender] = RequestedWithdrawal({            amount: amountToWithdraw,            time: now        });    } }

function withdraw() public {    if(requestedWithdrawals[msg.sender].amount > 0 && now > requestedWithdrawals[msg.sender].time + withdrawalWaitPeriod) {        uint amountToWithdraw = requestedWithdrawals[msg.sender].amount;

requestedWithdrawals[msg.sender].amount = 0;

if(!msg.sender.send(amountToWithdraw)) {            throw;        }    } }

速率限制

速率限制暂停或须要批准进行实质性更改。 例如,只容许存款人在一段时间内提取总存款的必定数量或百分比(例如,1天内最多100个ether) - 该时间段内的额外提款可能会失败或须要某种特别批准。 或者将速率限制作在合约级别,合约期限内只能发出发送必定数量的代币。

合约发布

在将大量资金放入合约以前,合约应当进行大量的长时间的测试。

至少应该:

  • 拥有100%测试覆盖率的完整测试套件(或接近它)

  • 在本身的testnet上部署

  • 在公共测试网上部署大量测试和错误奖励

  • 完全的测试应该容许各类玩家与合约进行大规模互动

  • 在主网上部署beta版以限制风险总额

自动弃用

在合约测试期间,你能够在一段时间后强制执行自动弃用以阻止任何操做继续进行。例如,alpha版本的合约工做几周,而后自动关闭全部除最终退出操做的操做。

modifier isActive() {    if (block.number > SOME_BLOCK_NUMBER) {        throw;    }    _; }

function deposit() public isActive() {    // some code }

function withdraw() public {    // some code }

#####限制每一个用户/合约的Ether数量

在早期阶段,你能够限制任何用户(或整个合约)的Ether数量 - 以下降风险。

Bug赏金计划

运行赏金计划的一些提示:

  • 决定赏金以哪种代币分配(BTC和/或ETH)

  • 决定赏金奖励的预算总额

  • 从预算来看,肯定三级奖励:    - 你愿意发放的最小奖励    - 一般可发放的最高奖励    - 设置额外的限额以免很是严重的漏洞被发现

  • 肯定赏金发放给谁(3是一个典型)

  • 核心开发人员应该是赏金评委之一

  • 当收到错误报告时,核心开发人员应该评估bug的严重性

  • 在这个阶段的工做应该在私有仓库进行,而且在Github上的issue板块提出问题

  • 若是这个bug须要被修复,开发人员应该在私有仓库编写测试用例来复现这个bug

  • 开发人员须要修复bug并编写额外测试代码进行测试确保全部测试都经过

  • 展现赏金猎人的修复;并将修复合并回公共仓库也是一种方式

  • 肯定赏金猎人是否有任何关于修复的其余反馈

  • 赏金评委根据bug的可能性和影响来肯定奖励的大小

  • 在整个过程当中保持赏金猎人参与讨论,并确保赏金发放不会延迟

有关三级奖励的例子,参见 Ethereum's Bounty Program:

(地址:https://bounty.ethereum.org/)

奖励的价值将根据影响的严重程度而变化。 奖励轻微的“无害”错误从0.05 BTC开始。 主要错误,例如致使协商一致的问题,将得到最多5个BTC的奖励。 在很是严重的漏洞的状况下,更高的奖励是可能的(高达25 BTC)。

6

安全相关的文件和程序

当发布涉及大量资金或重要任务的合约时,必须包含适当的文档。有关安全性的文档包括:

规范和发布计划

  • 规格说明文档,图表,状态机,模型和其余文档,帮助审核人员和社区了解系统打算作什么。

  • 许多bug从规格中就能找到,并且它们的修复成本最低。

  • 发布计划所涉及到的参考前文列出的详细信息和完成日期。

状态

  • 当前代码被部署到哪里

  • 编译器版本,使用的标志以及用于验证部署的字节码的步骤与源代码匹配

  • 将用于不一样阶段的编译器版本和标志

  • 部署代码的当前状态(包括未决问题,性能统计信息等)

已知问题

  • 合约的主要风险。例如, 你可能会丢掉全部的钱,黑客可能会经过投票支持某些结果

  • 全部已知的错误/限制

  • 潜在的攻击和解决办法

  • 潜在的利益冲突(例如,筹集的Ether将归入本身的腰包,像Slock.it与DAO同样)

历史记录

  • 测试(包括使用统计,发现的错误,测试时间)

  • 已审核代码的人员(及其关键反馈)

程序

  • 发现错误的行动计划(例如紧急状况选项,公众通知程序等)

  • 若是出现问题,就能够降级程序(例如,资金拥有者在被攻击以前的剩余资金占如今剩余资金的比例)

  • 负责任的披露政策(例如,在哪里报告发现的bug,任何bug赏金计划的规则)

  • 在失败的状况下的追索权(例如,保险,罚款基金,无追索权)

联系信息

  • 发现问题后和谁联系

  • 程序员姓名和/或其余重要参与方的名称

  • 能够询问问题的论坛/聊天室

7

安全工具

Oyente - 根据这篇文章分析Ethereum代码以找到常见的漏洞。

(地址:https://github.com/melonproject/oyente

http://www.comp.nus.edu.sg/~loiluu/papers/oyente.pdf)

solidity-coverage - Solidity代码覆盖率测试

(地址:https://github.com/sc-forks/solidity-coverage)

Solgraph - 生成一个DOT图,显示了Solidity合约的功能控制流程,并highlight了潜在的安全漏洞。

(地址:https://github.com/raineorshine/solgraph)

8

Linters

Linters经过约束代码风格和排版来提升代码质量,使代码更容易阅读和查看。

  • Solium - 另外一种Solidity linting。

    (地址:https://github.com/duaraghav8/Solium)

  • Solint - 帮助你实施代码一致性约定来避免你合约中的错误的Solidity linting

    (地址:https://github.com/SilentCicero/solint)

  • Solcheck - 用JS写的Solidity linter,(实现上)深受eslint的影响。

    (地址:https://github.com/federicobond/solcheck)

9

未来的改进

编辑器安全警告:编辑器将很快可以实现醒常见的安全错误,而不只仅是编译错误。 Solidity浏览器即将推出这些功能。

新的可以被编译成EVM字节码的函数式编程语言: 像Solidity这种函数式编程语言相比面向过程编程语言可以保证功能的不变性和编译时间检查。经过肯定性行为来减小出现错误的风险。(更多相关信息请参阅: Curry-Howard 一致性和线性逻辑)

本文来源:GitHub

2 contributors:AlexXiong9七、 tolak

本文翻译自:https://github.com/ConsenSys/smart-contract-best-practices。

为了使语句表达更加贴切,个别地方未按照原文逐字逐句翻译,若有出入请以原文为准。

如下是咱们的社区介绍,欢迎各类合做、交流、学习:)

image

阅读原文

相关文章
相关标签/搜索