安全编写以太坊的智能合约指南
本文翻译自zeppelin
的 https://blog.zeppelin.solutions/onward-with-ethereum-smart-contract-security-97a827e47702
如果你是以太坊开发的新手,我们推荐你在继续本文前,先读一下我们的以太坊智能合约指南(基本概念及环境搭建):https://medium.com/bitcorps-blog/the-hitchhikers-guide-to-smart-contracts-in-ethereum-848f08001f05#.6dob381ks
安全的开发以太坊的智能合约,是非常需要花费精力的。已经有一些好的指南以及汇总,比如 Consensys的智能合约最佳实践,和Solidity官方文档的安全指南。但除非真正写代码,这些概念很难被记住和理解。
本文会尝试一个有点不同的办法。首先解释提升智能合约安全的一些策略,并展示一些不遵从,而引起问题的例子。最后给一些已经调整地,可以直接使用的最佳实践。希望,这能帮助你创建避免某些不安全行为的肌肉记忆
,从而在写代码的时候意识到可能的风险。
不啰嗦了,进入正题吧。
尽早且明确的暴露问题
一个简单且强大的最佳实践是,让尽早且明确的暴露问题。接下来,看一个有问题的函数实现:
// 有问题的代码,不要使用!
contract BadFailEarly {
uint constant DEFAULT_SALARY = 50000;
mapping(string => uint) nameToSalary;
function getSalary(string name) constant returns (uint) {
if (bytes(name).length != 0 && nameToSalary[name] != 0) {
return nameToSalary[name];
} else {
return DEFAULT_SALARY;
}
}
}
为避免合约潜在的问题,或者让合约运行于一个不稳定或不一致的状态。上面例子中的函数getSalary
应该在返回结果前,检查参数。那现在的例子有什么问题呢,问题在于,如果条件不满足,将返回默认值。这将掩盖参数的严重问题,因为仍然可以按正常业务逻辑返回值。这虽然是一个比较极端的例子,但却非常常见,原因是大家在程序设计时,担心程序兼容性不够,所以设置一些兜底方案。但真相是,越快失败,越容易发现问题。如果我们不恰当的掩盖错误,错误将扩散到代码的其它地方,从而引起非常难以跟踪的不一致错误。下面是一个调整后的示例:
contract GoodFailEarly {
mapping(string => uint) nameToSalary;
function getSalary(string name) constant returns (uint) {
if (bytes(name).length == 0) throw;
if (nameToSalary[name] == 0) throw;
return nameToSalary[name];
}
}
这个版本的代码,还展示了另外一种推荐的编码方式,一种将条件预检查分开,分开判断,验证失败的方式。原因是可以使用Solidity提供的修改器的特性,来实现重用。
在支付时使用(pull)模式而不是(push)模式
每次ether
的转移,都需要考虑对应帐户,潜在的代码执行。一个接收的合约可以实现一个默认的回退函数,这个函数可能抛出错误。由此,我们永远要考虑在send
执行中的可能的错误。一个解决方案是,我们应该在支付时使用(pull)模式而不是(push)模式。来看一个看起来没有问题的,关于竞标函数的例子:
// 有问题的代码,请不要直接使用!
contract BadPushPayments {
address highestBidder;
uint highestBid;
function bid() {
if (msg.value < highestBid) throw;
if (highestBidder != 0) {
// return bid to previous winner
if (!highestBidder.send(highestBid)) {
throw;
}
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
上述的合约,调用了send
函数,检查了返回值,看起来是非常符合常理的。但它在函数中调用了send
函数,这带来了不安全,为什么?需要时刻记住的一点是,就像之前说的,send
会触发另外一个合约的代码执行。
假如某个竞标的地址,它会在每次有人转帐给他时throw
。而此时,其它人尝试追加价格竞标时会发生什么呢?那么send
调用将总是会失败,从而错误向上抛,让bid
函数产生一个异常。一个函数调用如果以错误结束,将会让状态不发生变更(所有的变化都将回滚)。这将意味着,没有人将能继续竞标,合约失效了。
最简单的解决方案是,将支付分离到另一个函数中,让用户请求(pull)金额,而不依赖于余下的合约逻辑:
contract GoodPullPayments {
address highestBidder;
uint highestBid;
mapping(address => uint) refunds;
function bid() external {
if (msg.value < highestBid) throw;
if (highestBidder != 0) {
refunds[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawBid() external {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
if (!msg.sender.send(refund)) {
refunds[msg.sender] = refund;
}
}
}
这次,我们使用一个mapping
来存储每个待退款的竞标者的信息,提供了一个withdraw
用于退款。如果在send
调用时抛出异常,仅仅只是那个有问题的竞标者受到影响。这是一个非常简单的模式,却解决了非常多的问题(比如,可重入)。所以,记住一点,当发送ether
时,使用(pull)模式而不是(push)模式。
我已经实现了一个使用这个模式的合约,可以方便的继承使用。
函数代码的顺序:条件,行为,交互
作为尽可能早的暴露问题的原则的一个延伸,一个好的实践是将你的函数结构化为:首先,检查所有前置的条件;然后,对合约的状态进行修改;最后,与其它合约进行交互。
条件,行为,交互。坚持使用这样的函数结构,将会让你避免大部分的问题。下面来看使用了这个模式的一个例子:
function auctionEnd() {
// 1. Conditions
if (now <= auctionStart + biddingTime)
throw; // auction did not yet end
if (ended)
throw; // this function has already been called
// 2. Effects
ended = true;
AuctionEnded(highestBidder, highestBid);
// 3. Interaction
if (!beneficiary.send(highestBid))
throw;
}
}
这首先符合尽可能早的暴露问题的原则,因为条件在一开始就进行了检查。它让存在潜在交互风险的,与其它合约的交互,留到了最后。
留意平台局限性
EVM有非常多的关于合约能做的硬限制。这些是平台级的安全考虑,如果你不知道的话,却可以会威胁你的合约安全。下面来看一个看起来正常的,雇员津贴管理的代码:
// 不安全的代码,不要直接使用!
contract BadArrayUse {
address[] employees;
function payBonus() {
for (var i = 0; i < employees.length; i++) {
address employee = employees[i];
uint bonus = calculateBonus(employee);
employee.send(bonus);
}
}
function calculateBonus(address employee) returns (uint) {
// some expensive computation ...
}
}
读完代码,业务实现非常直接,看起来也没有什么问题,但却潜藏三个问题,基于平台的一些独特性。
第一个问题是i
的类型将会是uint8
,因为如果要存0
,如果不指定类型,将自动选择一个占用空间最小的,恰当的类型,在这里将是uint8
。所以如果这个数组的大小超过255个元素,这个循环将永远不会结束,最终将导致gas
耗尽。应当在定义变量时,尽可能的不要使用var
,明确变量的类型,下面我们来修正一下上面的例子:
// 仍然是不安全的代码,请不要使用!
contract BadArrayUse {
address[] employees;
function payBonus() {
for (uint i = 0; i < employees.length; i++) {
address employee = employees[i];
uint bonus = calculateBonus(employee);
employee.send(bonus);
}
}
function calculateBonus(address employee) returns (uint) {
// some expensive computation ...
}
}
第二个你需要考虑的事情是gas
的限制。gas是以太坊的一种机制,来对资源的使用收费。每一个修改状态的功能调用都会花费gas
。假如calculateBonus
计算津贴时有些复杂的运算,比如需要跨多个项目计算利润。这将消耗非常多的gas
,将会很容易的达到交易和区块的gas
限制。如果一个交易达到了gas
的限制,所有的状态的改变都将会撤销,但消耗的gas
不会退回。当使用循环的时候,尤其要注意变量对gas
消耗的影响。让我们来优化一下上述的代码,将津贴计算与循环分开。但需要注意的是,拆开后仍然有数组变大后,带来的gas
消耗增长的问题:
// UNSAFE CODE, DO NOT USE!
contract BadArrayUse {
address[] employees;
mapping(address => uint) bonuses;
function payBonus() {
for (uint i = 0; i < employees.length; i++) {
address employee = employees[i];
uint bonus = bonuses[employee];
employee.send(bonus);
}
}
function calculateBonus(address employee) returns (uint) {
uint bonus = 0;
// some expensive computation modifying the bonus...
bonuses[employee] = bonus;
}
}
最后,还有一个关于调用栈调用深度的限制。EVM栈调用的硬限制是1024。这意味着如果嵌套调用的深度达到1024,合约调用将会失败。一个攻击者可以调用递归的调用我们的合约1023次,从而因为栈深度的限制,让send
失败。前述的(pull)模式,可以比较好的避免这个问题(译者注:原链接找不到了,但找下github上的讨论:https://github.com/OpenZeppelin/zeppelin-solidity/issues/15)。
下面是一个最终的修改版,解决了上述的所有问题:
import './PullPaymentCapable.sol';
contract GoodArrayUse is PullPaymentCapable {
address[] employees;
mapping(address => uint) bonuses;
function payBonus() {
for (uint i = 0; i < employees.length; i++) {
address employee = employees[i];
uint bonus = bonuses[employee];
asyncSend(employee, bonus);
}
}
function calculateBonus(address employee) returns (uint) {
uint bonus = 0;
// some expensive computation...
bonuses[employee] = bonus;
}
}
总结一下,需要记住的 1)使用的变量类型的限制,2)合约的gas
消耗,3)栈调用1024的限制。
测试用例
编写测试用例会占用大量的时间,但也能抵消你在添加新功能后回归问题需要花费的时间。回归问题具体是指在添加功能的修改过程中,导致之前的组件出现bug。
我将尽快写一个更加广泛的关于测试的指南,如果你比较好奇,可以先看看关于Truffle的测试指南(译者注:原文链接失效了,也许是这个)。
容错及自动bug奖励
首先感谢Peter Borah带来的这两个想法的灵感。代码审查和安全审核对保证安全来说还不足够。我们的代码需要做好最坏情况的准备。当我们的智能合约中有漏洞时,应该有一种方法可以安全的恢复。不止如此,我们也应该尽可能早的发现漏洞。下面是一个内置的自动bug奖励机制带来的作用。
下面我们就来看一个自动bug奖励的假设的代币管理的例子:
import './PullPaymentCapable.sol';
import './Token.sol';
contract Bounty is PullPaymentCapable {
bool public claimed;
mapping(address => address) public researchers;
function() {
if (claimed) throw;
}
function createTarget() returns(Token) {
Token target = new Token(0);
researchers[target] = msg.sender;
return target;
}
function claim(Token target) {
address researcher = researchers[target];
if (researcher == 0) throw;
// check Token contract invariants
if (target.totalSupply() == target.balance) {
throw;
}
asyncSend(researcher, this.balance);
claimed = true;
}
}
首先,正如前面所述,我们使用PullPaymentCapable
来让我们的支付更加安全。这个赏金合约,允许研究者创建当前我们审核的Token
合约的副本。任何人都可以参与到这个赏金项目,通过发送交易到这个赏金项目地址。如果任何研究者可以攻破他自己的Token
合约的拷贝,让一些本不该变的情况变化(比如这里,让总代币发行量与当前代币余额不一致),他将获得对应的赏金。一旦赏金被领取了,合约将不再继续接受新的资金(无名的函数被称为合约的回退函数,在每次合约接收ether
时自动执行)。
正如你看到的,它有一个非常好的特性是分离了合约,不需要对原始的Token
合约进行修改。这里有一个完整,任何人都可以使用的版本。
而对于容错性,我们需要修改我们原来的合约来增加额外的安全机制。一种简单的方案是允许合约的监督者可以冻结合约,作为一种紧急的机制。我们来看一个通过继承实现这种行为的例子:
contract Stoppable {
address public curator;
bool public stopped;
modifier stopInEmergency { if (!stopped) _ }
modifier onlyInEmergency { if (stopped) _ }
function Stoppable(address _curator) {
if (_curator == 0) throw;
curator = _curator;
}
function emergencyStop() external {
if (msg.sender != curator) throw;
stopped = true;
}
}
Stoppable
允许指定一个监督者,可以来停止整个合约。实现方式是,通过继承这个合约,在对应的功能上使用修改器stopInEmergency
和onlyInEmergency
,下面我们来看一个例子:
import './PullPaymentCapable.sol';
import './Stoppable.sol';
contract StoppableBid is Stoppable, PullPaymentCapable {
address public highestBidder;
uint public highestBid;
function StoppableBid(address _curator)
Stoppable(_curator)
PullPaymentCapable() {}
function bid() external stopInEmergency {
if (msg.value <= highestBid) throw;
if (highestBidder != 0) {
asyncSend(highestBidder, highestBid);
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdraw() onlyInEmergency {
suicide(curator);
}
}
在上面这个非常简单的例子中,bid
可以被一个监督者停止,监督者在合约创建时指定。StoppableBid
在正常情况下,只有bid
函数可以被调用,而当出现紧急情况时,监督者可以介入,并激活紧急状态。并让bid
函数不再可用,同时激活withdraw
功能。
在上面的例子中,紧急模式将允许监督者销毁合约,恢复资金。但在实际场景中,恢复的逻辑更为复杂(举例来说,需要返还资金给每个投资者)。这里有一个可停止合约的实现(译者注:给的这个链接无法访问了)。
限制可存入的资金
另一个保护我们智能合约远离攻击的方式是限制。攻击者最有可能针对管理数百万美元的高调合同。并不是所有的合约,有这样的高的资金量。尤其是当我们正在初期。在这种情形下,限制合约可以接收的资金量就将非常有用。最简单的方式,可以实现为一个余额的硬上限。
下面是一个简单的例子:
contract LimitFunds {
uint LIMIT = 5000;
function() { throw; }
function deposit() {
if (this.balance > LIMIT) throw;
...
}
}
回退函数里,会拒绝接收所有的直接支付。deposit
函数会首先检查合约的余额是否已经超限,超限将直接抛出异常。其它一些更有意思的,比如动态上限,管理限制也很容易实现。
简单和模块化的代码
安全来自,我们想写的与代码实际可以做的距离。这非常的难以验证,特别是当代码量又大,又混乱时。这就是为什么写简单和模块化的代码变得非常重要。
这意味着,函数应该尽可能的简单,代码之间的依赖应该极尽可能的少,文件应该尽可能的小,将独立的逻辑放进模块,每块的职责更加单一。
命名是我们在编码过程中表达我们意图的方式。想一个好的名字,尽可能的让名字清晰。
让我们来看一个关于Event的差命名的例子。看看DAO里的函数。其中的函数代码都太长了。
最大的问题是太长,而且功能复杂。尽可能的让你的函数短小,比如,最多不超过30到40行代码。理想情况下,你应该在1分钟内弄明白函数的意图。另一个问题是关于事件Transfer
在第685行的命名。这个名字与一个叫transfer的函数名只有一字之差。这将带来误解。一般来说,关于事件的推荐命名是使用Log
打头,这样的话,这个事件应该命名为LogTransfer
。
记住,尽可能的将你的合约写得简单,模块化,良好的命名。这将极大的帮助其它人和你自己审查你自己的代码。
不要从0开始写所有的代码
最后,正如一句格言所说,“不要从头发明你自己的加密币”。我想它也适用于智能合约代码。你的操作与钱有关,你的数据是公开的,你正在一个全新的成长中的平台上。代价非常高,糟蹋机会的人无处不在。
上述这些实践帮助我们写出更安全的合约。但最终,我们应该开发出更好的创建智能合约的工具。这里有一些先行者,包括better type systems,Serenity Abstractions 和the Rootstock platform。
现在已经有非常多的安全的代码,以及框架出现了。我们整合了一部分最佳实践到Github的资源库Open Zeppelin。欢迎看看以及贡献新代码,以及提供代码审查建议。
总结一下
回顾一下,这篇文章中描述的安全模式有:
- 尽早且明确的暴露问题。
- 使用(pull)模式而不是(push)模式
- 代码结构遵从:条件,行为,交互
- 注意平台限制
- 测试用例
- 容错及自动bug奖励
- 限制存入的资金
- 简单与模块化的代码
- 不要从零开始写代码
如果你想讨论与智能合约相关的问题,欢迎加入Slack,让我们一起来提升智能合约编程标准。
想要获得持续的更新,欢迎关注我们的Medium和Twitter;
(完)