引介 | 智能合约中的签名重放漏洞

密码学签名是区块链系统中的基本模块。使用对应的私钥对交易进行签名能够将交易发起人与特定帐户联系起来。如果没有此功能,区块链的记帐工作将无法正常进行。

许多在以太坊上部署的智能合约也有直接验证数字签名的功能,以使得一个或多个验证者可以通过提交离线创建的签名(甚至是由另一个智能合约生成的签名)来授权操作。这项验证通常被用于多重签名冷钱包或者投票合同,以便一起提交各种签名或委托授权。

此类实现中的常见漏洞是签名重放攻击。在 Cryptonics 对一个重要项目的智能合约审计中,我们遇到了这个问题的一个有趣例子。在本文中,我们将使用此示例来说明智能合约中签名验证是如何出错的。

与签名验证相关的漏洞通常是由于误解了底层的密码学原理和签名的目的而引起的。因此,在详细了解此特定漏洞之前,我们先快速了解一下密码学签名的工作原理。

密码学签名

大多数的密码学签名体系都基于公私钥对。私钥能够对数据进行签名,而且此签名能够被对应的公钥所验证。就像它的名字所暗示的一样,一个用户的公钥是公开的,而私钥则一定要保密。

对数据进行加密签名可实现两个重要属性:

  • 数据签名者可识别性,这是通过恢复签名者的公钥来实现的。
  • 数据完整的可验证性,意思是签名可以用于证明自签名以来数据未被修改。

虽然这些是非常强大的属性,但是需要重点注意的是签过名的数据本身不提供额外的保障。 签名不能保证一条消息的唯一性,也不能保证签名人就是发信人本身。 当然,加密签名可以被用于确认相关事实,但是应用程序也须执行必要的检查。我们可以在以太坊智能合约中调查以上事实。

以太坊中的签名验证

以太坊比特币 一样,采用 椭圆曲线数字签名算法 (ECDSA)和 secp256k1 曲线。智能合约可以通过系统方法 ecrecover 访问内置的 ECDSA 签名验证算法。以下示例展示了这个函数的用法:

address signer = ecrecover(msgHash, v, r, s);

这个方法的输入参数是签名值 v,r 和 s,以及签名数据的 keccak256 哈希值。它可以校验数据的完整性,即确认数字签名与数据的哈希值相对应,并且可以从签名中恢复签名者的以太坊地址(以太坊地址乃是从公钥中推导出来的)。

任何额外的检查,不论是检查签名地址是否为正确地址,还是检查被签名的消息是否唯一,都必须被手动添加进智能合约中。

经常有人误解了 ecrecover 的功能,然后搞出了安全漏洞。

签名重放漏洞

代码示例

让我们来看一下我们在最近的合约审计中发现的漏洞:

function unlock(
  address _to,
  uint256 _amount,
  uint8[] _v,
  bytes32[] _r,
  bytes32[] _s
)
  external
{
  require(_v.length >= 5);
  bytes32 hashData = keccak256(_to, _amount);
  for (uint i = 0; i < _v.length; i++) {
    address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
    require(_isValidator(recAddr));
  }
  to.transfer(_amount);
}

以上代码是我们所审计的代码的简化版本,为使代码变得简短易懂,它只保留了最基础的信息。但是其中的漏洞被完整地保留了下来。

被审计的合约是跨链桥接器的一部分,它能让数字资产从一个区块链转移到另一个上。以太币在以太坊智能合约中被锁定之时,另一条链上会创建出对应的资产。当资产在另一条链上被锁定或销毁时, unlock 函数可以释放先前被锁定的以太币。要实现这个效果时,跨链中继者可以提交一系列的验证者签名、一个解锁的数额以及一个目标地址。这个函数要求至少五个签名来解锁需要的数额并将资金传给接收方。而内部的 _isValidator 函数(为了简化,省略掉了具体实现)会检查一个地址具不具备验证者身份。

攻击情景

以上代码的问题在于被验证者用 ECDSA 算法签过名的消息中。这个消息只包含接收者的地址以及需要解锁的数量。 在这个消息中,并没有什么内容能防止相同的签名被多次重复使用 。想象如下的情景:

unlock

改进手段

以上情形被称为签名重放攻击。这种攻击能成功是由于我们无法验证所签名消息的唯一性,也不知道它之前是否被用过。

一个防止此类攻击的简单方法是在被签名数据中包含一个消息序列号或者 nonce。 以上代码的修正版如下:

public uint256 nonce;
function unlock(
  address _to,
  uint256 _amount, 
  uint256 _nonce,
  uint8[] _v,
  bytes32[] _r,
  bytes32[] _s
)
  external
{
  require(_v.length >= 5);
  require(_nonce == nonce++);
  bytes32 hashData = keccak256(_to, _amount, _nonce);
  for (uint i = 0; i < _v.length; i++) {
    address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
    require(_isValidator(recAddr));
  }
  to.transfer(_amount);
}

这段代码现在要求每一个成功的解锁调用都包含一个序列号。因为消息中得包含一个独一无二的数字,所以每次成功调用所要求的签名都是独一无二的。这表示之前观测到的消息对攻击者来说没用了,因为重放会失败。

签名验证的最佳模式

上述例子只是其中一个示例,演示了不能保证唯一型的签名如何被重放。在大部分情景中,确保签名能够与每一次调用形成唯一的匹配对预防重放攻击是非常重要的。

但是,这段代码并不完美。它并没有遵循签名验证的最佳实践。原因是它没有检查 可塑性签名, 我们应检查作为已接受签名一部分的 s 值是否在较低范围内。使用 ecrecover 函数的推荐流程可以在 Open Zeppelin 的 excellent ECDSA 库 中找到。事实上,在社区审计过的代码,比如 Open Zeppelin 上进行开发,总是一个好主意。

原文链接: https://medium.com/cryptronics/signature-replay-vulnerabilities-in-smart-contracts-3b6f7596df57

作者:Stefan Beyer

翻译&校对:TrumanW & 阿剑

本文由作者授权 EthFans 翻译及再出版。