智能合約安全審計入門系列之簽名重放。

作者:小白

背景概述

在上篇文章中我們講解了以太坊中的搶跑攻擊,了解了一筆交易從被發起者簽名到被礦工打包上鍊經歷了哪些環節。這次我們來了解一個經典的智能合約漏洞—— 簽名重放。

前置知識

按照正常的邏輯,每一筆簽名後的交易只能被執行一次。如果交易可被多次執行,那就存在重放攻擊(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 立場無關。文章內的信息僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。