Zero Knowledge Proofs (ZKP) are a crazy but black-magic mechanism for knowing that something happened without revealing what happened. For instance, proving that a person voted without giving up that person. Usually, this requires interactions or entities sending data back and forth. By hashing everything, it's possible to make proofs non-interactive via the Fiat-Shamir heuristic.
The system doesn't reexecute each instruction one by one. Instead, it uses algebraic constraints over the committed polynomials to calculate correctness. If an attacker can send executable code that was never executed or is an invalid state transition, this would violate the property of
soundness. The verification flow is done as follows:
- Fix public statement data.
- Parse proof payload, such as commitments, reduction messages, and openings.
- Rebuild the Fiat-Shamir challenges from the transcript data.
- Check constraint equations and check PCS/opening consistency.
- Accept the state change if all checks are consistent.
The Fiat-Shamir transformation is required because blockchains can't have real-time communication. Instead of generating a random number via a challenge protocol, the verifier hashes the existing state. This creates a really important invariant: the hash must include everything that affects verification BEFORE the challenges derived from it are used. If a value affects the equation but isn't used in the hash, it's possible to control that value in the rest of the protocol. The term absorbed is used a lot; it's similar to a hash function.
The SumCheck protocol proves that a polynomial sums to a claimed value over a Boolean hypercube. By doing some clever math, it's possible to turn this from 2^n to a single evaluation for a global check called the claimed_sum. This value is provided and MUST be part of the committed value. This issue was found 6 times in various codebases.
Finding values that meet the attack's needs requires advanced math that is either trivial to solve or requires some brute-forcing. Regardless, this missing binding happened in six separate implementations and was catastrophic.
So, why did this happen so many times? They give a few reasons in the post. First, academic papers usually describe interactive protocols rather than the Fiat-Shamir portion, making it ambiguous how to do it correctly. Second, zkVMs are very modular in structure. So, each layer assumes that the other performs transcript binding of a value. There is also heavy pressure for performance in the prover that makes checks nice to remove.
The fix is simple for each implementation: just bind the value to the challenge. At a higher-level it's important to make these less error-prone. One idea is to make the proof and the transcript (previous state) equal, so the values are automatically absorbed. A very similar bug can also be found
here. Even though I'm not a cryptography person, I still learned a lot from this post and really enjoyed it!