以Tornado.Cash为例,探讨zkp项目的延展性攻击。

以Tornado.Cash为例,探讨zkp项目的延展性攻击。

Tornado.Cash项目中的延展性攻击和防范措施

本文作者:Beosin安全研究专家Saya & Bryce

在上篇文章里,我们从原理的角度阐述了 Groth16 证明系统本身存在的延展性漏洞,本文中我们将以Tornado.Cash项目为例,魔改其部分电路和代码,介绍延展性攻击流程以及该项目中对应的防范措施,希望其他zkp项目方也引起注意。

Tornado.Cash架构

其中,Tornado.Cash使用snarkjs库进行开发,同样基于如下开发流程,后续就直接进行介绍,不熟悉该库的请阅读本系列第一篇文章。(Beosin | 深度剖析零知识证明zk-SNARK漏洞:为什么零知识证明系统并非万无一失?)

(图源:https://docs.circom.io/)

1 Tornado.Cash 架构

Tornado.Cash的交互流程中主要包含4个实体:

  • User:使用该DApp进行混币器隐私交易,包括存、取款。
  • Web LianGuaige:DApp的前端网页,网页上包含一些用户按钮。
  • Relayer:为防止链上节点记录发起隐私交易的ip地址等信息,该服务器会代替用户重放交易,进一步增强隐私性。
  • Contract:包含一个代理合约Tornado.Cash Proxy,该代理合约会根据用户存取款的金额选择指定的Tornado池子进行后续的存取款操作。目前已存在4个池子,金额分别为:0.1、1、10、100。

User首先在Tornado.Cash的前端网页上进行对应操作,触发存款或取款交易,接着由Relayer将其交易请求转发到链上的Tornado.Cash Proxy合约,并根据交易金额转发到对应的Pool中,最终进行存款和取款等处理。

Tornado.Cash作为一个混币器,其具体业务功能分为两部分:

  • deposit:当用户进行存款交易时,首先在前端网页上选择存入的代币(BNB、ETH等)和对应的数额,为了更好的确保用户的隐私性,只能存入四种金额数量;

用户存款时,服务器会生成两个31字节的随机数nullifier、secret,并将其拼接后进行pedersenHash运算即可得到commitment。随后,将nullifier+secret加上前缀作为note返回给用户。

用户存入的commitment会发送到链上的Tornado.Cash Proxy合约中,代理合约根据deposit的金额将数据转发至对应的Pool中,并将commitment作为叶子结点插入到merkle tree,并将计算出的root存储在Pool合约中。

  • withdraw:当用户进行取款交易时,首先在前端网页上输入deposit时返回的note数据和收款地址;

用户输入的note数据会在链下检索出所有Tornadocash的deposit事件,提取其中的commitment构建链下的Merkle tree,并根据用户给出的note数据(nullifier+secret)生成commitment并生成对应的Merkle Path和对应的root,并作为电路输入得到零知识SNARK proof。最后,再发起一笔withdraw交易到链上的Tornado.Cash Proxy合约中,接着根据参数跳转到对应的Pool合约中验证证明,将钱打入用户指定的接收者地址。

其中,Tornado.Cash的withdraw核心其实就是在不暴露用户持有的nullifier、secret的情况下,证明某个commitment存在于Merkle tree上。

具体的默克尔树结构如下:

Merkle树结构

2 Tornado.Cash 魔改漏洞版

2.1 Tornado.Cash 魔改

我们知道攻击者使用相同的nullifier、secret其实可以生成多个不同的Proof,那么如果开发者没有考虑到Proof重放造成的双花攻击,就会威胁到项目资金。因此,我们对Tornado.Cash进行魔改,实验证明存在延展性攻击漏洞,并提出相应的防范措施。

以下是Tornado.Cash中的部分代码:

function withdraw(
    bytes calldata _proof,
    bytes32 _root,
    bytes32 _nullifierHash,
    address payable _recipient,
    address payable _relayer,
    uint256 _fee,
    uint256 _refund
  ) external nonReentrant {
    require(_fee <= denomination, "Fee exceeds transfer value");
    require(!nullifierHashes[_nullifierHash], "The note has been already spent");
    require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
    require(
      verifier.verifyProof(
        _proof,
        [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
      ),
      "Invalid withdraw proof"
    );

    nullifierHashes[_nullifierHash] = true;
    _processWithdraw(_recipient, _relayer, _fee, _refund);
    emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}

为了防止攻击者使用同一个Proof进行双花攻击,Tornado.Cash在电路中增加了一个公共信号nullifierHash,它是由nullifier进行Pedersen哈希得到,可以作为参数传到链上,Pool合约再使用该变量标识一个正确的Proof是否已经被使用过。但是如果项目方不采用修改电路的方式,而是直接以记录Proof方式来防止双花,虽然可以节省开销,但是可能无法防止Groth16的延展性攻击。

针对这种情况,本文删除了电路中新增的nullifierHash公共信号,并将合约校验改为Proof校验。由于Tornado.Cash在每次withdraw时都会获取所有的deposit事件组建merkle tree再校验生成的root值是否在最近生成的30个之内,本文电路删除了merkleTree电路,仅保留了withdraw部分的核心电路。

include "../../../../node_modules/circomlib/circuits/bitify.circom";
include "../../../../node_modules/circomlib/circuits/pedersen.circom";

template CommitmentHasher() {
  signal input nullifier;
  signal input secret;
  signal output commitment;

  component commitmentHasher = Pedersen(496);
  component nullifierBits = Num2Bits(248);
  component secretBits = Num2Bits(248);

  nullifierBits.in <== nullifier;
  secretBits.in <== secret;

  for (var i = 0; i < 248; i++) {
    commitmentHasher.in[i + 248] <== secretBits.out[i];
  }

  commitment <== commitmentHasher.out[0];
}

component main = Withdraw(20);

为了防范重放漏洞,本文对合约进行了改动,新增了记录已使用过的Proof的功能。

modify:
require(
  !proofUsed[_proof],
  "The proof has been already used"
);

after:
proofUsed[_proof] = true;

2.2 实验验证

2.2.1 验证证明 — circom 生成的默认合约

首先,我们使用circom生成的默认合约进行验证,该合约由于根本没有记录任何已经使用过的Proof相关信息,攻击者可多次重放proof1造成双花攻击。

下图是通过验证使用相同的Proof进行多次重放的实验结果:

验证证明结果

2.2.2 验证证明 — 普通防重放合约

我们记录已使用过的正确Proof(proof1)中的一个值,以达到防止使用验证过的proof进行重放攻击的目的。

实验结果表明,同一input的proof1只能通过验证一次,无法继续重放;而伪造的proof2也无法通过验证。

2.2.3 验证证明 — Tornado.Cash放重放合约

为了一劳永逸地防范重放攻击,我们进一步修改合约代码,新增了记录原始电路input的功能。

实验证明,使用同一input的proof1只能通过验证一次,证明成功的proof1和伪造的proof2都无法再次通过校验。

3 总结和建议

本文介绍了Tornado.Cash项目中存在的延展性攻击漏洞以及防范措施。

从实验结果可以看出,普通的合约防重放措施虽然可以防止重放攻击,却无法防范Groth16的延展性攻击。因此,我们建议zkp项目在开发过程中注意以下几点:

  1. 业务逻辑允许插入相同数值节点的情况下,需谨慎处理组合随机数生成Merkle tree节点的逻辑。
  2. 对于Groth16开发,记录已使用过的Proof时需使用节点原始数据,而不能仅仅使用Proof相关数据标识。
  3. 在项目上线前,建议请安全审计公司对电路和合约进行全面审计,以保证项目的安全性。

以上是对Tornado.Cash项目中延展性攻击和防范措施的全面介绍,希望可以引起其他zkp项目方的注意。