安全编写以太坊的智能合约指南

in #cn7 years ago (edited)

本文翻译自zeppelinhttps://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

(html comment removed: more )

安全的开发以太坊的智能合约,是非常需要花费精力的。已经有一些好的指南以及汇总,比如 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允许指定一个监督者,可以来停止整个合约。实现方式是,通过继承这个合约,在对应的功能上使用修改器stopInEmergencyonlyInEmergency,下面我们来看一个例子:

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 systemsSerenity Abstractionsthe Rootstock platform

现在已经有非常多的安全的代码,以及框架出现了。我们整合了一部分最佳实践到Github的资源库Open Zeppelin。欢迎看看以及贡献新代码,以及提供代码审查建议。

总结一下

回顾一下,这篇文章中描述的安全模式有:

  1. 尽早且明确的暴露问题。
  2. 使用(pull)模式而不是(push)模式
  3. 代码结构遵从:条件,行为,交互
  4. 注意平台限制
  5. 测试用例
  6. 容错及自动bug奖励
  7. 限制存入的资金
  8. 简单与模块化的代码
  9. 不要从零开始写代码

如果你想讨论与智能合约相关的问题,欢迎加入Slack,让我们一起来提升智能合约编程标准。

想要获得持续的更新,欢迎关注我们的MediumTwitter

(完)

Sort:  

Congratulations @hh3755! You have received a personal award!

1 Year on Steemit
Click on the badge to view your Board of Honor.

Do not miss the last post from @steemitboard:
SteemitBoard World Cup Contest - Home stretch to the finals. Do not miss them!


Participate in the SteemitBoard World Cup Contest!
Collect World Cup badges and win free SBD
Support the Gold Sponsors of the contest: @good-karma and @lukestokes


Do you like SteemitBoard's project? Then Vote for its witness and get one more award!

Congratulations @hh3755! You received a personal award!

Happy Birthday! - You are on the Steem blockchain for 2 years!

You can view your badges on your Steem Board and compare to others on the Steem Ranking

Vote for @Steemitboard as a witness to get one more award and increased upvotes!