密碼學簽名是區塊鏈系統中的基本模塊。使用對應的私鑰對交易進行簽名能夠將交易發起人與特定帳戶聯系起來。如果沒有此功能,區塊鏈的記帳工
密碼學簽名是區塊鏈系統中的基本模塊。使用對應的私鑰對交易進行簽名能夠將交易發起人與特定帳戶聯系起來。如果沒有此功能,區塊鏈的記帳工作將無法正常進行。
許多在以太坊上部署的智能合約也有直接驗證數字簽名的功能,以使得一個或多個驗證者可以通過提交離線創建的簽名(甚至是由另一個智能合約生成的簽名)來授權操作。這項驗證通常被用于多重簽名冷錢包或者投票合同,以便一起提交各種簽名或委托授權。
此類實現中的常見漏洞是簽名重放攻擊。在 Cryptonics 對一個重要項目的智能合約審計中,我們遇到了這個問題的一個有趣例子。在本文中,我們將使用此示例來說明智能合約中簽名驗證是如何出錯的。
與簽名驗證相關的漏洞通常是由于誤解了底層的密碼學原理和簽名的目的而引起的。因此,在詳細了解此特定漏洞之前,我們先快速了解一下密碼學簽名的工作原理。
密碼學簽名
大多數的密碼學簽名體系都基于公私鑰對。私鑰能夠對數據進行簽名,而且此簽名能夠被對應的公鑰所驗證。就像它的名字所暗示的一樣,一個用戶的公鑰是公開的,而私鑰則一定要保密。
對數據進行加密簽名可實現兩個重要屬性:
· 數據簽名者可識別性,這是通過恢復簽名者的公鑰來實現的。
· 數據完整的可驗證性,意思是簽名可以用于證明自簽名以來數據未被修改。
雖然這些是非常強大的屬性,但是需要重點注意的是簽過名的數據本身不提供額外的保障。簽名不能保證一條消息的唯一性,也不能保證簽名人就是發信人本身。當然,加密簽名可以被用于確認相關事實,但是應用程序也須執行必要的檢查。我們可以在以太坊智能合約中調查以上事實。
以太坊中的簽名驗證
以太坊和比特幣一樣,采用橢圓曲線數字簽名算法(ECDSA)和 secp256k1 曲線。智能合約可以通過系統方法 ecrecover 訪問內置的 ECDSA 簽名驗證算法。以下示例展示了這個函數的用法:
address signer = ecrecover(msgHash, v, r, s);
這個方法的輸入參數是簽名值 v,r 和 s,以及簽名數據的 keccak256 哈希值。它可以校驗數據的完整性,即確認數字簽名與數據的哈希值相對應,并且可以從簽名中恢復簽名者的以太坊地址(以太坊地址乃是從公鑰中推導出來的)。
任何額外的檢查,不論是檢查簽名地址是否為正確地址,還是檢查被簽名的消息是否唯一,都必須被手動添加進智能合約中。
經常有人誤解了 ecrecover 的功能,然后搞出了安全漏洞。
簽名重放漏洞
代碼示例
讓我們來看一下我們在最近的合約審計中發現的漏洞:
function unlock(
address _to,
uint256 _amount,
uint8[] _v,
bytes32[] _r,
bytes32[] _s
)
external
{
require(_v.length >= 5);
bytes32 hashData = keccak256(_to, _amount);
for (uint i = 0; i < _v.length; i++) {
address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
require(_isValidator(recAddr));
}
to.transfer(_amount);
}
以上代碼是我們所審計的代碼的簡化版本,為使代碼變得簡短易懂,它只保留了最基礎的信息。但是其中的漏洞被完整地保留了下來。
被審計的合約是跨鏈橋接器的一部分,它能讓數字資產從一個區塊鏈轉移到另一個上。以太幣在以太坊智能合約中被鎖定之時,另一條鏈上會創建出對應的資產。當資產在另一條鏈上被鎖定或銷毀時, unlock 函數可以釋放先前被鎖定的以太幣。
要實現這個效果時,跨鏈中繼者可以提交一系列的驗證者簽名、一個解鎖的數額以及一個目標地址。這個函數要求至少五個簽名來解鎖需要的數額并將資金傳給接收方。而內部的 _isValidator 函數(為了簡化,省略掉了具體實現)會檢查一個地址具不具備驗證者身份。
攻擊情景
以上代碼的問題在于被驗證者用 ECDSA 算法簽過名的消息中。這個消息只包含接收者的地址以及需要解鎖的數量。在這個消息中,并沒有什么內容能防止相同的簽名被多次重復使用。想象如下的情景:
· Bob 在與以太坊連接的另一條鏈上有等價于 10ETH 的資產被他通過橋接器傳回了以太坊鏈上。
· Alice 是一個處理跨鏈交易的中繼者。她收集了必需的驗證者簽名,在所連接的鏈上鎖定了相對應的資產數量,并且調用 unlock 函數將 10ETH 從合約中釋放給 Bob。
· 包含一系列簽名值的交易能夠在區塊鏈上公開讀取。
· Bob 現在可以復制這個簽名值的序列并且自己提交一個一模一樣的解鎖函數調用請求。這個解鎖的操作能夠再一次成功,導致又一個 10ETH 被發送給Bob。
· Bob 能夠重復這個過程直到智能合約中的以太坊被耗盡。
改進手段
以上情形被稱為簽名重放攻擊。這種攻擊能成功是由于我們無法驗證所簽名消息的唯一性,也不知道它之前是否被用過。
一個防止此類攻擊的簡單方法是在被簽名數據中包含一個消息序列號或者 nonce。以上代碼的修正版如下:
public uint256 nonce;
function unlock(
address _to,
uint256 _amount,
uint256 _nonce,
uint8[] _v,
bytes32[] _r,
bytes32[] _s
)
external
{
require(_v.length >= 5);
require(_nonce == nonce++);
bytes32 hashData = keccak256(_to, _amount, _nonce);
for (uint i = 0; i < _v.length; i++) {
address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
require(_isValidator(recAddr));
}
to.transfer(_amount);
}
這段代碼現在要求每一個成功的解鎖調用都包含一個序列號。因為消息中得包含一個獨一無二的數字,所以每次成功調用所要求的簽名都是獨一無二的。這表示之前觀測到的消息對攻擊者來說沒用了,因為重放會失敗。
簽名驗證的最佳模式
上述例子只是其中一個示例,演示了不能保證唯一型的簽名如何被重放。在大部分情景中,確保簽名能夠與每一次調用形成唯一的匹配對預防重放攻擊是非常重要的。
但是,這段代碼并不完美。它并沒有遵循簽名驗證的最佳實踐。原因是它沒有檢查可塑性簽名,我們應檢查作為已接受簽名一部分的 s 值是否在較低范圍內。使用 ecrecover 函數的推薦流程可以在 Open Zeppelin 的 excellent ECDSA 庫中找到。事實上,在社區審計過的代碼,比如 Open Zeppelin 上進行開發,總是一個好主意。(作者: Stefan Beyer)