智能合约安全审计入门篇 —— 签名重放

区块链安全 1年前 (2023) admin
171 0 0

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: 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/

往期回顾

一周动态 | Web3 安全事件总损失约 265.5 万美元

慢雾出品 | 2023 上半年区块链安全与反洗钱报告

慢雾出品|Web3 行业供应链安全指南

慢雾:Web3 钱包安全审计项升级

慢雾:Cellframe 被黑简析

智能合约安全审计入门篇 —— 签名重放

慢雾导航


慢雾科技官网

https://www.slowmist.com/


慢雾区官网

https://slowmist.io/


慢雾 GitHub

https://github.com/slowmist


Telegram

https://t.me/slowmistteam


Twitter

https://twitter.com/@slowmist_team


Medium

https://medium.com/@slowmist


知识星球

https://t.zsxq.com/Q3zNvvF

原文始发于微信公众号(慢雾科技):智能合约安全审计入门篇 —— 签名重放

版权声明:admin 发表于 2023年7月18日 下午4:40。
转载请注明:智能合约安全审计入门篇 —— 签名重放 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...