智能合约安全审计入门篇 —— 签名重放
By:小白
背景概述
在上篇文章中我们讲解了以太坊中的抢跑攻击,了解了一笔交易从被发起者签名到被矿工打包上链经历了哪些环节。这次我们来了解一个经典的智能合约漏洞 —— 签名重放。
前置知识
按照正常的逻辑,每一笔签名后的交易只能被执行一次。如果交易可被多次执行,那就存在重放攻击(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: 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 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: 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
) 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;
}
}
总结
作为开发者,当业务涉及签名数据使用时,应当评估正常业务设计是否允许签名被重放。如果不允许,应当加入 Nonce 参数。
作为审计者,在审计中,所有签名的使用都需要检查是否能够被重放。如果满足重放特征,需要及时与项目方沟通是否符合业务设计。
[2] Solidity by Example. https://solidity-by-example.org/hacks/signature-replay/
往期回顾
慢雾导航
慢雾科技官网
https://www.slowmist.com/
慢雾区官网
https://slowmist.io/
慢雾 GitHub
https://github.com/slowmist
Telegram
https://t.me/slowmistteam
https://twitter.com/@slowmist_team
Medium
https://medium.com/@slowmist
知识星球
https://t.zsxq.com/Q3zNvvF