当前位置: 首页 > 文档资料 > Open Zeppelin >

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

优质
小牛编辑
139浏览
2023-12-01

本文翻译自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允许指定一个监督者,可以来停止整个合约。实现方式是,通过继承这个合约,在对应的功能上使用修改器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 systems,Serenity Abstractions 和the Rootstock platform。

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

总结一下

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

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

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

想要获得持续的更新,欢迎关注我们的Medium和Twitter;

(完)