智能合约安全审计入门系列之签名重放。

作者:小白

背景概述

在上篇文章中我们讲解了以太坊中的抢跑攻击,了解了一笔交易从被发起者签名到被矿工打包上链经历了哪些环节。这次我们来了解一个经典的智能合约漏洞 —— 签名重放。

前置知识

按照正常的逻辑,每一笔签名后的交易只能被执行一次。如果交易可被多次执行,那就存在重放攻击(Replay Attack)的风险。想了解重放攻击就要先了解一笔签名后的交易是由哪些参数构成的:

type txdata struct {    AccountNonce uint64          `json:"nonce"    gencodec:"required"`    Price        *big.Int        `json:"gasPrice" gencodec:"required"`    GasLimit     uint64          `json:"gas"      gencodec:"required"`    Recipient    *common.Address `json:"to"       rlp:"nil"`    Amount       *big.Int        `json:"value"    gencodec:"required"`    Payload      []byte          `json:"input"    gencodec:"required"`
// Signature values V *big.Int `json:"v" gencodec:"required"` R *big.Int `json:"r" gencodec:"required"` S *big.Int `json:"s" gencodec:"required"`
// This is only used when marshaling to JSON. Hash *common.Hash `json:"hash" rlp:"-"`}

下面我们来分别解释各个参数的意义:

AccountNonce

AccountNonce(账户 Nonce)是一个与账户相关的数值,用于确保区块链网络中交易的顺序性和唯一性。在区块链中,每个账户都有一个关联的 Nonce(也称为 transaction count 或 transaction index),用于标识该账户发起的交易数量。它是本期文章的主角,主要作用是防止重放攻击。每当一个账户发送一笔交易时,Nonce 值就会自动增加。网络接收到交易时,会检查交易中的 Nonce 与账户当前的 Nonce 是否匹配,以确保交易按照正确的顺序进行,同时也防止了交易被重复执行。

那么 Nonce 是如何保证交易的顺序性的呢?

由于区块链是一个分布式系统,多个节点可能同时接收到不同的交易。通过设置 Nonce 可以对交易进行排序,确保它们按照正确的顺序被打包在区块中。

在以太坊中,Nonce 有以下几条规则:

  • 当 Nonce 太小(小于当前账户的 Nonce 值),交易会被直接拒绝;
  • 当 Nonce 太大(大于当前账户的 Nonce 值),交易会一直处于队列中;
  • 当发送了一个比较大的 Nonce 值,此时该交易处于 pending 状态。如果想要执行该笔交易,需要继续发送多笔交易。当账户 Nonce 值累积到提交的高度时,交易就可以被执行;
  • 交易队列最多只能保存 64 个从同一个账户发出的交易,也就是说,如果要批量转账,同一节点不能发出超过 64 笔交易;
  • 当某节点队列中还有交易,如果停止 Geth 客户端,队列中的交易会被清除掉;
  • 当前 Nonce 合适,但是账户余额不足时,交易也会被以太坊拒绝。

Price

这笔交易的 GasPrice。(见上期文章

GasLimit

这笔交易允许消耗的最大 Gas 量。(见上期文章

Recipient

交易接收者如果为空,说明该笔交易是合约部署交易。

Recipient 同样也是以太坊代码中的字段,转换为 Json 时被重命名为 to。交易的接收者在 to 字段中指定,这包含一个 20 字节的以太坊地址,地址可以是 EOA 或合约地址。

以太坊不会进一步验证这个字段,任何 20 字节的值都被认为是有效的。即使接收者地址无人认领,该交易仍然有效。如果是一笔转账交易,以太币会被发送到指定地址,但是因为指定地址的私钥无法获得,相当于失去了这笔钱的控制权,也就丢失了 ETH 。

Amount

Amount 表示交易转移的 ETH 数量,单位是 wei。

Payload

当该笔交易为合约部署交易时,Payload 字段表示部署合约的内容,否则表示调用合约的代码,其中包含要调用的函数签名和函数参数。

VRS

V:是一个用于恢复公钥的值,它表示签名所使用的椭圆曲线上的点的索引。在以太坊中,V 的取值通常为 27 或 28,有时也可能是其他值。实际取值是通过以下公式计算得出的:V = ChainId * 2 + 35 + RecoveryId,其中 ChainId 是用于标识以太坊网络的链 ID,RecoveryId 是一个用于恢复公钥的附加值。在以太坊伦敦升级之后,主网链 ID 是单独编码的,不再包含在签名 V 值内。签名 V 值变成了一个简单的校验位(“签名 Y 校验位”),不是 0 就是 1,具体取决于使用椭圆曲线上的哪个点。

R:是签名的一部分,表示椭圆曲线上的 x 坐标。

S:是签名的另一部分,表示椭圆曲线上的一个参数。

使用 VRS 格式的签名可以方便地提取公钥,并用于验证签名的有效性。需要注意的是,虽然 VRS 格式的签名在以太坊中被广泛使用,但在其他加密货币和区块链网络中,可能存在不同的签名格式。

以太坊中的签名重放大致可以分为两种:

1. 不同链签名重放攻击

不同链签名重放,顾名思义,就是在不同链上重放交易,从而完成攻击。最典型的例子就是 2022 年 6 月 9 日 Optimism 被盗 2000 万 OP 事件,该事件就是由于 Gnosis Safe 钱包合约交易签名不符合 EIP155 标准(这里先简单介绍一下 EIP155 标签:符合 EIP155 标准的签名会对 9 个 RLP 编码元素 (nonce, gasPrice, gas, to, value, data, chainId, 0, 0) 进⾏哈希,其中包含了 chainId,因此符合 EIP155 标准的签名 V 值就为 {0,1} + chainId * 2 + 35 。⽽对不符合

EIP155 标准的签名,其只对 6 个元素进⾏哈希 (nonce, gasPrice, gas, to, value, data) ,因此签名后的 V 值为 {0,1} + 27)。我们不难发现,不使用 EIP155 标准的交易签名中没有 chainId,从而造成一笔交易可以被拿到其他链上进行重放。

著名的 Optimism 事件的攻击者就是利用这一点,找到 Gnosis Safe 在以太坊主网部署 proxy factory 合约的 input data,并在 Optimism 链上重放该笔交易部署 proxy factory 合约,接下来不断调用该合约创建钱包合约直至 Nonce 达到可以生成存着 2000 万 OP 的地址的高度,从而获取该地址的控制权,完成攻击。该攻击细节可查看《2000 万 OP 代币被盗关键:交易重放》和《深度解析 Optimism 被盗 2000 万来龙去脉!真 tm 精彩!》[1]。

2. 同链签名重放攻击

同链签名重放攻击一般是利用合约漏洞完成攻击的,最典型的就是合约在生成签名时没有加入 Nonce,从而导致签名数据可以被无限次使用,造成危害。本篇文章主要介绍这种攻击的原理以及如何防范此类攻击。

下面我们还是通过漏洞合约来详细了解:

合约示例

// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet { using ECDSA for bytes32;
address[2] public owners;
constructor(address[2] memory _owners) payable { owners = _owners; }
function deposit() external payable {}
function transfer(address _to, uint _amount, bytes[2] memory _sigs) external { bytes32 txHash = getTxHash(_to, _amount); require(_checkSigs(_sigs, txHash), "invalid sig");
(bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
function getTxHash(address _to, uint _amount) public view returns (bytes32) { return keccak256(abi.encodePacked(_to, _amount)); }
function _checkSigs( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) { bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) { address signer = ethSignedHash.recover(_sigs[i]); bool valid = signer == owners[i];
if (!valid) { return false; } }
return true; }}

可以看到,MultiSigWallet 合约是一个 2/2 多签合约,两名 Owner 将钱存入合约,转账时需要发起人调用 MultiSigWallet.getTxHash() 并传入转账目标及转账数量,得到哈希后,两个 Owner 使用私钥签名,得到两个签名数据后才能成功调用 MultiSigWallet.transfer() 将钱转出。下面我们还是请出 Evil,Bob 和 Alice 这三个老朋友演绎攻击流程:

1. Alice 与 Bob 共同创建了 MultiSigWallet 合约,并同时向合约中打入 10 个 ETH(此时合约中有 20 个 ETH);

2. Alice 告诉 Bob 自己男朋友 Evil 过生日,想给他转 1 个 ETH 作为生日礼物;

3. Alice 调用 MultiSigWallet.getTxHash() 将 Evil 的 EOA 地址与转账数量传入,得到交易哈希;

4. Bob 与 Alice 同时为生成的交易哈希签名;

5. Alice 将两份签名数据交给 Evil 让他自己取;

6. Evil 发现自己可以使用两份签名无限调用 MultiSigWallet.transfer() 给自己重复转账 1 ETH;

7. Evil 调用 20 次 MultiSigWallet.transfer() 将合约中的 20 个 ETH 全部拿走。

攻击分析

其实很简单,Alice 调用 MultiSigWallet.getTxHash() 生成的交易哈希中并未加入 Nonce,这将导致签名数据可以被无限使用,所以 Evil 可以使用两份签名数据无限取款。

修复合约

只要在交易哈希中加入 Nonce 就可以完美防止重放,我们来看修复合约是如何实现的:

// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet { using ECDSA for bytes32;
address[2] public owners; mapping(bytes32 => bool) public executed;
constructor(address[2] memory _owners) payable { owners = _owners; }
function deposit() external payable {}
function transfer( address _to, uint _amount, uint _nonce, bytes[2] memory _sigs) external { bytes32 txHash = getTxHash(_to, _amount, _nonce); require(!executed[txHash], "tx executed"); require(_checkSigs(_sigs, txHash), "invalid sig");
executed[txHash] = true;
(bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
function getTxHash( address _to, uint _amount, uint _nonce) public view returns (bytes32) { return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce)); }
function _checkSigs( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) { bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) { address signer = ethSignedHash.recover(_sigs[i]); bool valid = signer == owners[i];
if (!valid) { return false; } }
return true; }}

可以看到修复合约在 MultiSigWallet.getTxHash() 中加入了 Nonce 来生成交易哈希,并且合约还加入了 executed 列表,当调用 MultiSigWallet.transfer() 转账后,会将签名对应的状态改为 executed[txHash] = true,这是为了防止重复提交转账。

总结

作为开发者,当业务涉及签名数据使用时,应当评估正常业务设计是否允许签名被重放。如果不允许,应当加入 Nonce 参数。

作为审计者,在审计中,所有签名的使用都需要检查是否能够被重放。如果满足重放特征,需要及时与项目方沟通是否符合业务设计。

参考链接:

[1] https://jason.mirror.xyz/Vwdd1b2V52q9A2rvRTvGI8lkIkY4DkMLPGxAld_gKko

[2] Solidity by Example. https://solidity-by-example.org/hacks/signature-replay/

免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。