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