大量零知識證明項目由于錯誤地使用了某個 zkSNARKs 合約庫,引入「輸入假名 (Input Aliasing) 」漏洞,可導(dǎo)致偽造證明、雙花、重放等攻擊行為
大量零知識證明項目由于錯誤地使用了某個 zkSNARKs 合約庫,引入「輸入假名 (Input Aliasing) 」漏洞,可導(dǎo)致偽造證明、雙花、重放等攻擊行為發(fā)生,且攻擊成本極低。眾多以太坊社區(qū)開源項目受影響,其中包括三大最常用的 zkSNARKs 零知開發(fā)庫 snarkjs、ethsnarks、ZoKrates,以及近期大熱的三個混幣(匿名轉(zhuǎn)賬)應(yīng)用 hopper、Heiswap、Miximus。這是一場由 Solidity 語言之父 Chris 兩年前隨手貼的一段代碼而引發(fā)的血案。
雙花漏洞:最初暴露的問題
semaphore 是一個使用零知識證明技術(shù)的匿名信號系統(tǒng),該項目由著名開發(fā)者 barryWhiteHat 此前的混幣項目演化而來。
俄羅斯開發(fā)者 poma 最先指出該項目可能存在雙花漏洞[1]。
問題出在第 83 行代碼[2],請仔細(xì)看。
該函數(shù)需要調(diào)用者構(gòu)造一個零知識證明,證明自己可從合約中提走錢。為了防止「雙花」發(fā)生,該函數(shù)還讀取「廢棄列表」,檢查該證明的一個指定元素是否被標(biāo)記過。如果該證明在廢棄列表中,則合約判定校驗不通過,調(diào)用者無法提走錢。開發(fā)者認(rèn)為,這樣一來相同的證明就無法被重復(fù)提交獲利,認(rèn)為此舉可以有效防范雙花或重放攻擊。
然而事與愿違,這里忽視了一個致命問題。攻擊者可根據(jù)已成功提交的證明,利用「輸入假名」漏洞,對原輸入稍加修改便能迅速「偽造證明」,順利通過合約第 82 行的零知識證明校驗,并繞過第 83 行的防雙花檢查。
該問題最早可追溯到 2017 年,由 Christian Reitwiessner 大神,也就是 Solidity 語言的發(fā)明者,提供的 zkSNARKs 合約密碼學(xué)實現(xiàn)示例[3]。其后,幾乎以太坊上所有使用 zkSNARKs 技術(shù)的合約,都照用了該實現(xiàn)。因此都可能遭受以下流程的攻擊。
混幣應(yīng)用:該安全問題的重災(zāi)區(qū)
零知識證明技術(shù)在以太坊上最早和最廣泛的應(yīng)用場景是混幣合約,或匿名轉(zhuǎn)賬、隱私交易。由于以太坊本身不支持匿名交易,而社區(qū)對于隱私保護(hù)的呼聲越來越強烈,因此涌現(xiàn)出不少熱門項目。這里以混幣合約的應(yīng)用場景為例,介紹「輸入假名」漏洞對零知項目的安全威脅。
混幣合約或匿名轉(zhuǎn)賬涉及兩個要點:
證明自己有一筆錢
證明這筆錢沒有花過
為了方便理解,這里簡單描述一下流程:
A 要花一筆錢。
A 要證明自己擁有這筆錢。A 出示一個 zkproof,證明自己知道一個 hash (HashA) 的 preimage,且這個 hash 在以 root 為標(biāo)志的 tree 的葉子上,且證明這個 preimage 的另一種 hash 是 HashB。其中 HashA 是 witness,HashB 是 public statement。由于 A 無需暴露 HashA,所以是匿名的。
合約校驗 zkproof,并檢查 HashB 是否在廢棄列表中。若不在,則意味著這筆錢未花過,可以花(允許 A 的此次調(diào)用)。
如果可以花,合約需要把 HashB 放入廢棄列表中,標(biāo)明以 HashB 為代表的錢已經(jīng)被花過,不能再次花了。
上面代碼中的第 82 行 verifyProof(a, b, c, input) 用來證明這筆錢的合法性,input[] 是 public statement,即公共參數(shù)。第 83 行通過 require(nullifiers_set[input[1]] == false) 校驗這筆錢是否被花過。
很多 zkSNARKs 合約尤其是混幣合約,核心邏輯都與第 82 行和 83 行類似,因此都存在同樣的安全問題,可利用「輸入假名」漏洞進(jìn)行攻擊。
漏洞解析:一筆錢如何匿名地重復(fù)花 5 次?
上面 verifyProof(a, b, c, input) 函數(shù)的作用是根據(jù)傳入的數(shù)值在橢圓曲線上進(jìn)行計算校驗,核心用到了名為 scalar_mul() 的函數(shù),實現(xiàn)了橢圓曲線上的標(biāo)量乘法[4]。
/// @return the product of a point on G1 and a scalar, i.e.
/// p == p.scalar_mul(1) and p.add(p) == p.scalar_mul(2) for all points p.
function scalar_mul(G1Point point, uint s) internal returns (G1Point r) {
uint[3] memory input;
input[0] = p.X;
input[1] = p.Y;
input[2] = s;
bool success;
assembly {
success := call(sub(gas, 2000), 7, 0, input, 0x80, r, 0x60)
// Use "invalid" to make gas estimation work
switch success case 0 { invalid() }
}
require (success);
}
我們知道以太坊內(nèi)置了多個預(yù)編譯合約,進(jìn)行橢圓曲線上的密碼學(xué)運算,降低 zkSNARKs 驗證在鏈上的 Gas 消耗。函數(shù) scalar_mul() 的實現(xiàn)則調(diào)用了以太坊預(yù)編譯 7 號合約,根據(jù)EIP 196實現(xiàn)了橢圓曲線 alt_bn128 上的標(biāo)量乘法[5]。下圖為黃皮書中對該操作的定義,我們常稱之為 ECMUL 或 ecc_mul。
密碼學(xué)中,橢圓曲線的 {x,y} 的值域是一個基于 mod p 的有限域,這個有限域稱之為 Zp 或 Fp。也就是說,一個橢圓曲線上的一個點 {x,y} 中的 x,y 是 Fp 中的值。一條橢圓曲線上的某些點構(gòu)成一個較大的循環(huán)群,這些點的個數(shù)稱之為群的階,記為q?;跈E圓曲線的加密就在這個循環(huán)群中進(jìn)行。如果這個循環(huán)群的階數(shù)(q)為質(zhì)數(shù),那么加密就可以在 mod q 的有限域中進(jìn)行,該有限域記作 Fq。
一般選取較大的循環(huán)群作為加密計算的基礎(chǔ)。在循環(huán)群中,任意選定一個非無窮遠(yuǎn)點作為生成元 G(通常這個群的階q是個大質(zhì)數(shù),那么任選一個非零點都是等價的),其他所有的點都可以通過 G+G+.... 產(chǎn)生出來。這個群里的元素個數(shù)為 q,也即一共有 q 個點,那么我們可以用 0,1,2,3,....q-1 來編號每一個點。在這里第 0 個點是無窮遠(yuǎn)點,點1 就是剛才提到的那個 G,也叫做基點。點2 就是 G+G,點3 就是 G+G+G。
于是當(dāng)要表示一個點的時候,我們有兩種方式。第一種是給出這個點的坐標(biāo) {x,y},這里 x,y 屬于Fp。第二種方式是用 n*G 的方式給出,由于 G 是公開的,于是只要給出 n 就行了。n 屬于 Fq。
看一下 scalar_mul(G1Point point, uint s) 函數(shù)簽名,以 point 為生成元,計算 point+point+.....+point,一共 n 個 point 相加。這屬于使用上面第二種方法表示循環(huán)群中的一個點。
在 Solidity 智能合約實現(xiàn)中需要使用 uint256 類型來編碼 Fq,但 uint256 類型的最大值是大于q 值,那么 會出現(xiàn)這樣一種情況:在 uint256 中有多個數(shù) 經(jīng)過 mod 運算之后都會對應(yīng)到同一個 Fq中的值。比如 s 和 s + q 表示的其實是同一個點,即第s個點。這是因為在循環(huán)群中點q 其實等價于 點0(每個點分別對應(yīng) 0,1,2,3,....q-1)。同理,s + 2q 等均對應(yīng)到點s 。我們把可以輸入多個大整數(shù)會對應(yīng)到同一個 Fq中的值 這一現(xiàn)象稱作「輸入假名」,即這些數(shù)互為假名。
以太坊 7 號合約實現(xiàn)的橢圓曲線是 y^2 = ax^3+bx+c。p 和 q 分別如下。
這里的 q 值即上文中提到的群的階數(shù)。那么在 uint256 類型范圍內(nèi),共有 uint256_max / q 個,算下來也就是最多會有 5 個整數(shù)代表同一個點( 5 個「輸入假名」)。
這意味著什么呢?讓我們回顧上面調(diào)用 scalar_mul(G1Point point, uint s) 的 verifyProof(a, b, c, input) 函數(shù),input[] 數(shù)組里的每個元素實際就是 s。對于每個 s,在 uint256 數(shù)據(jù)類型范圍內(nèi),會最多存在其他 4 個值,傳入后計算結(jié)果與原值一致。
因此,當(dāng)用戶向合約出示零知識證明進(jìn)行提現(xiàn)后,合約會把 input[1] (也就是某個 s)放入作廢列表。用戶(或其他攻擊者)還可以使用另外 4 個值再次進(jìn)行證明提交。而這 4 個值之前并沒有被列入「廢棄列表」,因此“偽造”的證明可以順利通過校驗,利用 5 個「輸入假名」一筆錢可以被重復(fù)花 5 次,而且攻擊成本非常低!
還有更多受影響的項目
存在問題的遠(yuǎn)遠(yuǎn)不止 semaphore 一個。其他很多以太坊混幣項目以及 zkSNARKs 項目都存在同樣的允許「輸入假名」的問題。
這些項目在社區(qū)熱度都十分高,其中 Heiswap 更是被人們稱為 「Vitalik 最喜愛的項目」。
這當(dāng)中,影響最大的要數(shù)幾個大名鼎鼎的 zkSNARKs 庫或框架項目,包括 snarkjs、ethsnarks、ZoKrates 等。許多應(yīng)用項目會直接引用或參考他們的代碼進(jìn)行開發(fā),從而埋下安全隱患。因此,上述三個項目迅速進(jìn)行了安全修復(fù)更新。另外,多個利用了 zkSNARKs 技術(shù)的知名混幣項目,如 hopper、Heiswap、Miximus 也立刻進(jìn)行了同步修復(fù)。
「輸入假名」漏洞的解決方案
事實上,所有使用了該 zkSNARKs 密碼學(xué)合約庫的項目都應(yīng)該立即開展自查,評估是否受影響。那么應(yīng)該如何修復(fù)這個問題?
所幸的是,修復(fù)很簡單。僅需在驗證函數(shù)中添加對輸入?yún)?shù)大小的校驗,強制要求 input 值小于上面提到的 q 值。即嚴(yán)禁「輸入假名」,杜絕使用多個數(shù)表示同一個點。
暴露的深層問題值得反思
該「輸入假名」導(dǎo)致的安全漏洞值得社區(qū)認(rèn)真反思。我們再回顧一下整個故事。2017 年 Christian 在 Gist 網(wǎng)站貼出了自己的 zkSNARKs 合約計算實現(xiàn)。作為計算庫,我們可以認(rèn)為他的實現(xiàn)并沒有安全問題,沒有違反任何密碼學(xué)常識,完美地完成了在合約中進(jìn)行證明驗證的工作。
事實上,作為 Solidity 語言的發(fā)明者,Christian 在這里當(dāng)然不會犯任何低級錯誤。而兩年后的今天,這段代碼卻引發(fā)了如此的安全風(fēng)波。兩年多的時間內(nèi),可能有無數(shù)同行和專家看過或使用過這段只有兩百多行的代碼,卻沒有發(fā)現(xiàn)任何問題。
核心問題出在哪里?可能出在底層庫的實現(xiàn)者和庫的使用者雙方間對于程序接口的理解出現(xiàn)了偏差。換句話說:底層庫的實現(xiàn)者對于應(yīng)用開發(fā)者的不當(dāng)使用方式欠缺考慮;而上層應(yīng)用開發(fā)者沒有在使用中沒有深入理解底層實現(xiàn)原理和注意事項,進(jìn)行了錯誤的安全假設(shè)。
所幸的是,目前常見的 zkSNARKs 合約庫都火速進(jìn)行了更新,從底層庫層面杜絕「輸入假名」。安比(SECBIT)實驗室認(rèn)為,底層庫的更新誠然能夠很大程度上消除掉后續(xù)使用者的安全隱患,但若該問題的嚴(yán)重性沒有得到廣泛地宣傳和傳播,依舊會有開發(fā)者不幸使用到錯誤版本的代碼,或者是根據(jù)錯誤的教程進(jìn)行開發(fā)(就像因為整數(shù)溢出而歸零的那些 Token 一樣),從而埋下安全隱患。
「輸入假名」漏洞不禁讓我們回想起此前頻繁曝出的「整數(shù)溢出」漏洞。二者相似之處頗多:都是源于大量開發(fā)者的錯誤假設(shè);都與 Solidity 里的 uint256 類型有關(guān);波及面都十分廣;網(wǎng)絡(luò)上也都流傳著很多存在隱患的教程代碼或者庫合約。
但顯然「輸入假名」漏洞顯然更難檢測,潛伏時間更長,需要的背景知識更多(涉及到復(fù)雜的橢圓曲線和密碼學(xué)理論)。安比(SECBIT)實驗室認(rèn)為,隨著 zkSNARKs、零知識證明應(yīng)用、隱私技術(shù)的興起,社區(qū)會涌現(xiàn)出更多的新應(yīng)用,而背后暗藏的更多安全威脅可能會進(jìn)一步暴露出來。希望這波新技術(shù)浪潮中,社區(qū)能充分吸收以往的慘痛教訓(xùn),重視安全問題。(作者:p0n1)