如何使用Solidity编写安全的智能合约代码?

张亚宁   |     |   4602 次阅读

原文:https://blog.ethereum.org/2016/06/10/smart-contract-security/


译者注:在经历了DAO攻击事件之后,每一个Solidity使用者都重新审视自己的智能合约,加强代码的安全性。


Solidity自2014年10月份开始,不论是以太坊的网络还是虚拟机都经历了真实世界的考验,现在gas的消耗已经和当初有非常大的变化。而且,一些早期的设计决定已经从Serpent中被替换掉。在过去的几个月,一些最初被认为是最佳实践的例子和模式,已经被验证可行,而有些被证实为反模式。因为上述原因,我们最近更新了一些Solidity的文档,但是大多数人并没有一直关注git的提交,我会在这里重点描述一些结果。

我不会在这里讨论小的问题,大家可以阅读文档来了解它们。

发送Ether
发送ether应该是Solidity里最简单的事情之一,但是它有一些微妙之处多数人都没有意识到。重要的是,最好的办法是,由ether的收款人发起支付。以下是一个关于拍卖的不好的例子。

// THIS IS A NEGATIVE EXAMPLE! DO NOT USE!
contract auction { 
  address highestBidder;
  uint highestBid;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      highestBidder.send(highestBid); // refund previous bidder 
    highestBidder = msg.sender;
    highestBid = msg.value; 
  }
}

因为最大的调用栈深度是1024,一个新的投标者可以一直增加调用栈到1023,然后调用bid(),这样就会导致send(highestBid)调用被悄悄地失败(也就是前一个投标者没有收到返回金额),但是现在新的投标者仍然是最高的投标者,检查send是否成功的一个方法是,检查它的返回值:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
if (highestBidder != 0) 
  if (!highestBidder.send(highestBid)) throw;

throw语句引起当前的调用回滚。这是一个糟糕的主意,因为接受方,如果实现了 fallback 函数function() { throw; }总是能强制ether转移失败,然后就会导致没有其它人可以报价高于它。

唯一的防止这两种情况的办法是,转换发送模式为提款模式,使收款方控制以太币转移:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
contract auction { 
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      refunds[highestBidder] += highestBid;
    highestBidder = msg.sender;
    highestBid = msg.value;
  } 
  function withdrawRefund() {
    if (msg.sender.send(refunds[msg.sender])) 
      refunds[msg.sender] = 0;
  }
} 

为什么说上面的合约依然是“负面的例子”?因为gas机制,合约实际上是没问题的,但是它依然不是一个好的例子。因为它不能阻止代码执行,在收款方参与send时。这意味着,当send函数在进行时,收款方可以返回调用withdrawRefund。在这时,返还金额仍然是一样的,因此他们可以再次获得金额。在这个特殊的例子里,它不能如此,因为收款方只有一定的gas额度(2100 gas),它不可能用这么多gas来执行另外一次send。但是以下的代码就可以被攻击:msg.sender.call.value(refunds[msg.sender])()

经过考虑到上面的情况,下面的代码应该是没有问题的(当然它仍然不是一个完整的拍卖合约的例子):

contract auction { 
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      refunds[highestBidder] += highestBid; 
    highestBidder = msg.sender; 
    highestBid = msg.value;
  } 
  function withdrawRefund() { 
    uint refund = refunds[msg.sender];
    refunds[msg.sender] = 0;
    if (!msg.sender.send(refund)) 
      refunds[msg.sender] = refund;
  }
}

注意我们不使用throw在失败的send函数上,因为我们可以手动的回滚所有的状态变化,而不需要使用throw导致很多副作用。

使用Throw
Throw字句可以经常十分方便地回滚任何状态上的变化作为方法调用的一部分(或许整个交易都依赖于这个函数如何调用)。尽管如此,你必须明白,它也可以导致所有的gas被消耗,因此它很昂贵,而且会停止调用当前的函数。因此,我推荐在下面这几种情况下使用。

1. 回滚发送到当前函数的ether
如果一个函数不是为了接受ether或者不在当前状态或者不是当前参数,你应该使用throw来拒绝ether。使用throw是唯一的可靠的办法来返还ether,因为gas和调用栈深度的问题:收款方可能在fallback函数里存在错误,造成消耗太多gas而无法收到ether或者在函数调用时,在一个充满恶意的包含很深调用栈的上下文中(或许甚至在执行这个函数之前)。

记住偶然发送ether到一个合约失败并不总是用户体验错误:你无法预测在哪种顺序下或者在何时transaction会被加到block中。如果合约被写成只接受第一个transaction,包含在其它transactions里的ether必须被拒绝。

2. 回滚已经调用过的函数结果
如果你调用函数在其它合约上,你永远不知道他们是如何执行的。这意味着,这些调用的结果也无法知道,因此唯一回滚这些结果的办法是throw。当然你也可以使你的合约不在第一时间调用这些函数,如果你知道必须要回滚这些结果,但是有一些例子说明你只有在这些事实发生之后才能知道这些结果。

循环和块gas限制
在一个块里使用gas是有一个限制的。这个限制是动态的,但是很难去增长它。这意味着在你的合约里的每一个函数调用,在所有(合理的)情况下应该保持在低于某一个特定的gas数量。以下是一个关于投票的糟糕例子:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
contract Voting { 
  mapping(address => uint) voteWeight;
  address[] yesVotes;
  uint requiredWeight;
  address beneficiary;
  uint amount;
  function voteYes() {
    yesVotes.push(msg.sender);
  } 
  function tallyVotes() { 
     uint yesVotes; 
     for (uint i = 0; i < yesVotes.length; ++i) 
        yesVotes += voteWeight[yesVotes[i]]; 
     if (yesVotes > requiredWeight) 
        beneficiary.send(amount);
  }
}

这个合约实际上有几个问题,但是我想在这里强调的是关于循环的问题:假设投票的权重是可以转移和分割的,就像tokens(就像DAO tokens那样)。这意味着,你可以创建任意多个你自己的克隆。创建这样的克隆会增加tallyVotes函数里的循环,至到它消耗超过在一个单独块里可用gas额度。

这种情况适用于所有使用循环的情况,同样包括那些隐晦的循环,比如说当你拷贝storage里一个数组或者字符串时。另外,如果循环的长度被调用者控制,有任意长度的循环也是没有问题的,例如你遍历一个被当参数传递进来的数组。但是永远不要造成这种情况,让遍历被某一方控制,但是他又不能承受遍历失败。

另外,这也是为什么我们在DAO合约里有冻结帐户的概念:投票权重在投票进行时就进行了计算,为了防止循环被卡住,如果在投票结束后,投票权重没有被满足,我们可以进行第二轮投票,只需要转移你的tokens然后再次投票即可。

接受ether/fallback函数
如果你想要你的合约接受ether,你必须使fallback函数便宜。它只能使用2300gas,既不允许任何storage写入也不允许function调用任何其它ether发送。基本上,你只需要在fallback函数里log下event,这样外部的调用可以被反应到事实上。

 
4 人喜欢