Q1ngying

今朝梦醒与君别,遥盼春风寄相思

0%

重放

前言:
按照正常的逻辑,每一笔签名后的交易只能被执行一次。如果交易可以被多次执行,那就存在重放攻击(Replay Attack) 的风险。

交易签名

在了解重放攻击之前,我们需要先了解一下一笔签名后的交易是由哪些参数构成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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 笔交易;
  • 当前 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 标准的签名会对 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 精彩!

2.同链签名重放攻击

同链签名重放攻击一般是利用合约漏洞完成攻击的,最典型的就是合约在生成签名时没有加入 Nonce,从而导致签名数据可以被无限次使用,造成危害。

合约实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// SPDX-License-Identifier: MIT
pragma 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 return (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 就可以完美防止重放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//SPDX-License-Identifier: MIT
pragma 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) publlic 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 = ethSignenHash.recover(_sigs[i]);
bool valid = signer == owners[i];

if(!valid) {
return false;
}
}

return true;
}
}

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

总结

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

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

来源:慢雾公众号

https://mp.weixin.qq.com/s?__biz=MzU4ODQ3NTM2OA==&mid=2247498135&idx=1&sn=247b149eb5b0b1c5b4348ce7512adf03&chksm=fdde8710caa90e06922e46f142a57fd8d6de5718e84547e6c548c45e301172f14dcebf73f00f&scene=178&cur_album_id=1378653641065857025#rd