{ 跳至内容 }

智能合约安全

作者:Christian Reitwiessner 发布于 2016年6月10日

安全警报

本文最初发表在以太坊博客.

Solidity 于 2014 年 10 月启动,当时以太坊网络和虚拟机都还没有进行任何实际测试,当时的 Gas 成本与现在也大不相同。此外,一些早期的设计决策是从 Serpent 继承而来。在过去的几个月里,最初被认为是最佳实践的一些示例和模式暴露在现实中,其中一些实际上变成了反模式。因此,我们最近更新了一些Solidity 文档,但由于大多数人可能不会关注该存储库的 GitHub 提交流,因此我想在这里重点介绍一些发现。

我不会在这里讨论那些小问题,请在文档中阅读。

发送 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 语句会导致当前调用被回滚。这是一个糟糕的主意,因为接收方(例如,通过将回退函数实现为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 函数仍在进行时,接收方可以回调到 withdrawRefund。此时,退款金额仍然相同,因此他们将再次获得该金额,依此类推。在此特定示例中,它不起作用,因为接收方仅获得 Gas 津贴(2100 Gas),并且无法使用此 Gas 量执行另一个发送操作。但是,以下代码容易受到此攻击: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,因为我们能够手动回滚所有状态更改,并且不使用 throw 具有更少的副作用。

使用 Throw

throw 语句通常非常方便,可以回滚作为调用(或整个交易,具体取决于函数的调用方式)一部分对状态进行的所有更改。但是,您必须意识到,它还会导致所有 Gas 被消耗,因此代价高昂,并且可能会阻止对当前函数的调用。因此,我建议在以下情况下使用它

1. 回滚到当前函数的 Ether 转账

如果某个函数不打算接收 Ether 或不在当前状态下或使用当前参数接收 Ether,则应使用 throw 拒绝 Ether。由于 Gas 和堆栈深度问题,使用 throw 是可靠地发送回 Ether 的唯一方法:接收方在其回退函数中可能存在错误,该错误消耗了过多的 Gas,因此无法接收 Ether,或者该函数可能在具有过高堆栈深度的恶意上下文中被调用(甚至可能在调用函数之前)。

请注意,意外地向合约发送 Ether 并不总是用户体验失败:您永远无法预测交易以何种顺序或在何时添加到区块中。如果合约被编写为仅接受第一个交易,则必须拒绝其他交易中包含的 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);
  }
}

该合约实际上存在几个问题,但我在这里想重点介绍的是循环问题:假设投票权重像代币一样可转让和可分割(以 DAO 代币为例)。这意味着您可以创建任意数量的自己的克隆。创建此类克隆将增加 tallyVotes 函数中循环的长度,直到它消耗的 Gas 超过单个区块内可用的 Gas 量。

这适用于任何使用循环的情况,即使在合约中没有显式显示循环,例如在存储中复制数组或字符串时也是如此。同样,如果循环的长度由调用方控制,例如,如果您迭代作为函数参数传递的数组,则可以拥有任意长度的循环。但切勿创建循环长度由非唯一受害者控制的情况。

作为旁注,这是我们现在在 DAO 合约中引入阻塞账户概念的原因之一:投票权重在投票时进行计算,以防止循环卡住,如果投票权重在投票期结束之前不会固定,则可以通过简单地转移您的代币然后再次投票来投第二票。

接收 Ether / 回退函数

如果希望您的合约通过常规的 send() 调用接收 Ether,则必须使它的回退函数廉价。它只能使用 2300 Gas,这既不允许任何存储写入,也不允许发送 Ether 的函数调用。基本上,您在回退函数中唯一应该做的事情是记录事件,以便外部进程可以对该事实做出反应。当然,合约的任何函数都可以接收 Ether,并且不受该 Gas 限制的约束。函数实际上必须拒绝发送给它们的 Ether,如果它们不想接收任何 Ether,但我们正在考虑在未来的某个版本中可能反转此行为。

上一篇

下一篇

参与进来

GitHub

Twitter

Mastodon

Matrix

了解更多

博客文档使用案例贡献关于论坛

2024Solidity 团队

安全策略

行为准则