作者:大酱,慢雾安全团队
前言
之前的文章我们盘点了 ZKP 主流实现方案技术特点,我们提到了一些 ZKP 算法存在延展性风险,本篇我们继续从实战角度为大家展示它的攻击原理及防御方法。
漏洞简介
ZKP 的延展性攻击,是指给定敌手一个合法证明,敌手在不知道见证的条件下自己生成新的合法证明。
并不是所有的证明系统都存在延展性攻击风险,实际上这个问题目前主要存在于 Groth16 证明系统中。那么问题来了,目前已经有那么多的证明系统,为什么还要坚持使用 Groth16 呢?其实也没得选择,Groth16 生成的证明体积实在是小到极致,验证也极其之快,在计算代价十分昂贵的区块链上,使用 Groth16 似乎是最理想的选择。
延展性风险会带来哪些风险呢?我们可以想象一下如果有这么一个存款系统,它使用用户提交的 ZKP 证明来验证其身份,验证成功则可以提款。由于这个系统的验证过程是公开的,任何人都可以获取到这个证明,如果我们是用证明值本身做为取款登记,如果这个证明被获取到并进行变换,那就可被利用来多次提款。漏洞的利用需要看具体的场景,但我们可以看到延展性首先会带来双花的风险。
数学原理
理解攻击原理我们首先需要从理解算法开始,这需要有一定的密码学基础,感兴趣的同学可以自行寻找 Groth16 算法资料。这里我们直击漏洞根源:验证函数。
我们来看一下这个验证函数公式:
如果没有对这些字母一一从头介绍,我想很难看懂它在表达什么,但我觉得过多的介绍是不需要了,记好公式左边的 “A 乘 B” 就这么简单,然后我们开始施展数学魔法。咒语如下:
根据群的结合律,那么上面公式中:
这只是其中一种比较简单的构造方法,另外还有一种构造方法 [1],这里不做阐述,因为我们已经得到想要的东西了。
工程实现
有了上面的公式,就可以在工程上实现 Groth16 证明的延展。选择一个要伪造证明的对象,获取它的 proof,例如:
{
pi_a: [
'17566212007750634279332191898019870443899908963707812937725971557556988121113',
'13653824972036797689593667463260040326059024360787769597142078414930263663703',
'1'
],
pi_b: [
[
'14906111038352923510344648516413952434168552622848767570599399834157918236589',
'15289017543994496306320102143103349779456992442925111629326024552687168229256'
],
[
'18841235948006283310515755114762069779103481848435391875780416574913227842443',
'6835281862874020275059416795628130939104366467185014410026268177455413514889'
],
[ '1', '0' ]
],
pi_c: [
'21641806348662631815866837255154640732047306895903168385641666607914783128458',
'2082587994352117459125871298218148663854896572836176277773049196516560449682',
'1'
],
protocol: 'groth16',
curve: 'bn128'
}
我们看这样的一个 proof,pi_a, pi_b, pi_c 就是上面公式里描述的 A, B, C,这个证明使用的是 BN128 曲线,然后我们需要去寻找支持 BN128 曲线开发库。这里我们选择 ffjavascript,它是一个基于 Javascript 的有限域库,支持 BN128, BLS12381 曲线。
首先,我们任意构造一个域上的元素及它的逆元:
const X = F.e("123456");
const invX = F.inv(X);
然后,分别相乘,核心代码如下:
const A = curve.G1.fromObject(proof.pi_a);
const B = curve.G2.fromObect(proof.pi_b);
new_pi_a = curve.G1.timesScalar(A, X); //A'=x*A
new_pi_b = curve.G2.timesScalar(B, invX); //B'=x^{-1}*B
最后,用 new_pi_a, new_pi_b 去替换原 proof,得到新的 proof:
{
pi_a: [
'6515337738552169645617263495374285821912767490069335826295120714428977813009',
'10671874016637483602721966808912960491553808325993800847672325376634242358838',
'1'
],
pi_b: [
[
'20523135654483520737281403147507843211011765855706506084021355785019229409285',
'4032527486736971273144842057682931136787425732029780739716144011227563817375'
],
[
'9389285843105460816015935120908213706233585149018458753845466963847282799614',
'7207137211649923819130654483456848273137049778520784010268635580504303221849'
],
[ '1', '0' ]
],
pi_c: [
'21641806348662631815866837255154640732047306895903168385641666607914783128458',
'2082587994352117459125871298218148663854896572836176277773049196516560449682',
'1'
],
protocol: 'groth16',
curve: 'bn128'
}
至此,我们已经成功构造出了新的证明,把 proof 放到验证函数中去验证可以发现它能通过验证。
防范
如何防范 Groth16 延展性攻击呢?可以参考这四种方法:
- 对 proof 进行签名,验证者在验证 proof 同时也验证签名是否正确;
- 参考 TornadoCash 在电路的公开输入中增加 nullifier 值,nullifier 确保证明对应的公开输入只能被使用一次;
- 在电路中将证明者的身份信息(如以太坊的 msg.sender)加入到公开输入中,然后验证者就可以对提交证明的人进行身份验证;
- 使用其它的证明系统,参考我们之前的文章。
总结
Groth16 存在延展性攻击风险,可通过简单的计算伪造出新的证明,实际运用中需要注意防止出现双花攻击。
参考链接:
[1]. https://medium.com/ppio/how-to-generate-a-groth16-proof-for-forgery-9f857b0dcafd