5/5
## Securing Proof Inputs: The Double Hashing Solution This lesson delves into a critical vulnerability discovered in the Panagram ZK-SNARK project, where a user could potentially cheat by manipulating proof inputs. We'll explore the flaw and then implement a robust solution using a double-hashing technique. **The Vulnerability: Reusing the Answer Hash** The Panagram game requires a user to guess a secret word. In the original design, the user's guess is hashed (let's call this `guess_hash`), and this hash is compared against the hash of the correct secret word (`answer_hash`) directly within the Noir ZK-SNARK circuit. The `answer_hash` is a public input to the circuit. While the frontend application (`Input.tsx`) correctly hashes the user's typed guess, reduces it modulo the `FIELD_MODULUS`, and passes this `guess_hash` to the proof generation script (`generateProof.ts`), a malicious actor is not bound by this frontend. A savvy user could write their own script, bypassing the UI, to construct the inputs for the proof generation. In such a script, they could directly set the `guess_hash` input to be identical to the publicly known `answer_hash`. The original Noir circuit (`main.nr`) contained a simple assertion: ```noir // Old main.nr logic fn main(guess_hash: Field, answer_hash: pub Field, address: pub Field) { // ... other logic related to address ... assert(guess_hash == answer_hash); } ``` If an attacker sets their `guess_hash` input to the value of `answer_hash`, this assertion would pass. Consequently, a valid proof could be generated, indicating a correct guess, even if the attacker never knew the pre-image of the `answer_hash` (the actual secret word). This fundamentally breaks the game's integrity. **The Solution: Implementing Double Hashing** To counteract this vulnerability, we will modify the system to use a double-hashing scheme. Instead of storing and comparing a single hash of the secret word, the `answer_hash` (which is public) will now be a *double hash* of the secret word. The user's `guess_hash` (which remains a single hash of their guessed word) will then be hashed *again inside the circuit* before it's compared to this public double-hashed answer. This ensures that simply knowing the public `answer_double_hash` is not enough to forge a valid `guess_hash`, as the circuit itself performs an additional hashing step on the private input. Let's walk through the necessary code modifications: **1. Update `Nargo.toml` for Keccak256 Hashing** To perform hashing within the Noir circuit, we need a hashing library. We'll use the `keccak256` library. Add it as a dependency in your `circuits/Nargo.toml` file: ```toml [dependencies] keccak256 = { tag = "v0.1.0", git = "https://github.com/noir-lang/keccak256" } ``` This allows us to import and use Keccak256 hashing functions directly in our Noir code. **2. Modify the Noir Circuit (`circuits/src/main.nr`)** The core logic change happens in the `main.nr` circuit file. * First, we'll rename the public input `answer_hash` to `answer_double_hash` for clarity, reflecting its new nature. * The `guess_hash` (which is the single hash of the user's word, passed as a private input) will now be hashed again inside the circuit using Keccak256 before comparison. Here's the updated `main` function: ```noir use dep::keccak256; // Import the Keccak256 dependency fn main(guess_hash: Field, answer_double_hash: pub Field, address: pub Field) { // Note: The address.pow_32 logic and other constraints may still be present // but are omitted here for brevity to focus on the hashing change. // 1. Decompose the guess_hash (which is a Field) into its byte representation. // A Field element representing a hash is typically 32 bytes. let guess_hash_decomposed: [u8; 32] = guess_hash.to_be_bytes(); // 2. Hash the decomposed guess_hash again using Keccak256. // The keccak256 function from the library expects an array of u8 // and the message size (number of bytes to hash). // Since guess_hash_decomposed is a [u8; 32] array, the message_size is 32. let guess_double_hash_decomposed: [u8; 32] = keccak256::keccak256(guess_hash_decomposed, 32); // 3. Convert the resulting byte array (the double hash) back into a Field element. let guess_double_hash = Field::from_be_bytes(guess_double_hash_decomposed); // 4. Assert that the internally calculated double_hash of the guess // matches the publicly provided answer_double_hash. assert(guess_double_hash == answer_double_hash); } ``` **Important Note on `message_size`**: When using the `keccak256` function, the `message_size` parameter must accurately reflect the length of the byte array being hashed. In our case, `guess_hash_decomposed` is a 32-byte array, so `message_size` must be `32`. An incorrect `message_size` (e.g., `1`) would lead to an incorrect hash and logical errors. **3. Regenerate the Verifier Contract** After modifying the Noir circuit, the verifier contract must be regenerated to reflect these changes. * First, clean up previous build artifacts: Delete the `circuits/target` directory. * Navigate to the `circuits` directory in your terminal. * Compile the circuit: `nargo compile` * Generate the Verifying Key (VK). The command using `bb` (Barretenberg) would look similar to this, ensuring the oracle hash matches what might be used elsewhere (e.g. Keccak for compatibility if needed): `bb write_vk --oracle_hash keccak -b ./target/zk_panagram -o ./target/` (Note: Ensure the binary path `-b` points to your compiled circuit, e.g., `zk_panagram.json` or just the project name which `bb` can often infer, and the output path `-o` is to a file, typically `vk` or `vk.bin` within the target directory.) * Generate the Solidity verifier contract: `bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol` (Here, `-k` points to the generated Verifying Key file.) * Finally, move the newly generated `Verifier.sol` from `circuits/target/` to your `contracts/src/` directory, replacing the old one. **4. Modify Solidity Tests (`contracts/test/Panagram.t.sol`)** Our Solidity tests need to be updated to work with the new double-hashing scheme. * The constants representing the answer must now be double-hashed. For example, if the secret word is "triangles": ```solidity // Assuming FIELD_MODULUS is a defined constant // Original word: "triangles" // Single hash (this is what the user effectively provides as guess_hash) bytes32 constant CORRECT_GUESS_SINGLE_HASH = bytes32(uint256(keccak256(abi.encodePacked("triangles"))) % FIELD_MODULUS); // ANSWER is now the double hash (this is the public input answer_double_hash) bytes32 constant ANSWER_DOUBLE_HASH = bytes32(uint256(keccak256(abi.encodePacked(CORRECT_GUESS_SINGLE_HASH))) % FIELD_MODULUS); ``` * When calling your test helper function `_getProof` (or directly interacting with proof generation), the parameters must align: * The `guess` parameter (which becomes `guess_hash` in the circuit) should be the single-hashed guess. * The `correctAnswer` parameter (which becomes `answer_double_hash` in the circuit) should be the double-hashed answer. For example, in `testCorrectGuessPasses`: ```solidity // In Panagram.t.sol // Make sure ANSWER_DOUBLE_HASH is set as the current answer in the contract for the test // ... bytes memory proof = _getProof(CORRECT_GUESS_SINGLE_HASH, ANSWER_DOUBLE_HASH, user); // ... assertions for successful verification ... ``` * Similar adjustments are needed for other test cases, like `testStartSecondRound` with a new word (e.g., "outnumber"), ensuring its corresponding `NEW_ANSWER` constant is also a double hash. * In `testIncorrectGuessFails`, you'll simulate a user submitting an incorrect guess. The `correctAnswer` parameter for `_getProof` should be the actual `ANSWER_DOUBLE_HASH` for the current round. The `guess` parameter will be the single hash of an incorrect word. ```solidity // In testIncorrectGuessFails, assuming ANSWER_DOUBLE_HASH is for "triangles" bytes32 incorrectWordSingleHash = bytes32(uint256(keccak256(abi.encodePacked("wrongword"))) % FIELD_MODULUS); // We expect proof generation or verification to fail // vm.expectRevert(...); // Depending on how _getProof or verification handles failures bytes memory incorrectProof = _getProof(incorrectWordSingleHash, ANSWER_DOUBLE_HASH, user); // ... assertions for expected failure ... ``` The circuit will internally double-hash `incorrectWordSingleHash`. This result will not match `ANSWER_DOUBLE_HASH` (the double hash of "triangles"), causing the circuit's assertion to fail, thus leading to an invalid proof or a failure during proof generation. **5. Modify Proof Generation Script (`js-scripts/generateProof.ts`)** The JavaScript/TypeScript script responsible for generating proofs must also be updated to use the new public input name. In your `generateProof.ts` or similar: ```typescript // Inside your proof generation logic const inputs = { // Private Inputs guess_hash: inputsArray[0], // This is the single hash of the user's guess // Public Inputs answer_double_hash: inputsArray[1], // Changed from answer_hash address: inputsArray[2], }; // ... rest of the proof generation using these inputs ``` Ensure `inputsArray[1]` correctly provides the double-hashed answer when this script is invoked. **6. Run Tests** After all modifications, compile your contracts and run your tests to confirm everything is working as expected: `forge test -vvv` (or simply `forge test`) If all tests pass, you have successfully implemented the double-hashing solution, mitigating the input manipulation vulnerability. ## Addressing Anagram Brute-Force Vulnerabilities While double hashing secures the proof input mechanism, another potential vulnerability exists, related to the nature of the Panagram game itself: brute-forcing the anagram. **The Vulnerability Explained** An attacker has access to two key pieces of public information: 1. The letters of the current pangram/anagram (e.g., "O, U, T, N, U, M, B, E, R"). 2. The `answer_double_hash` (the public input to the ZK-SNARK circuit, representing the double-hashed secret word). With this information, an attacker can perform an offline brute-force attack: 1. **Generate Word Candidates:** Write a script to generate all possible valid English words (or, more exhaustively, all permutations) from the given set of letters. 2. **Hash and Compare:** For each generated word candidate: a. Calculate its single hash: `potential_single_hash = keccak256(candidate_word) % FIELD_MODULUS`. b. Calculate its double hash: `potential_double_hash = keccak256(potential_single_hash) % FIELD_MODULUS`. c. Compare this `potential_double_hash` with the known public `answer_double_hash`. 3. **Identify Secret Word:** If a match is found, the attacker has successfully determined the secret word without interacting with the game's guessing mechanism or ZK proof system. **Mitigation Challenges** This type of vulnerability is inherent to games where: * The search space for the secret (i.e., the number of valid words from the anagram) is relatively small. * A hash of the secret (even a double hash) is publicly known. ZK-SNARKs prove knowledge of a *witness* (in this case, the `guess_hash` that leads to the correct `answer_double_hash`) without revealing the witness itself. However, they don't prevent an attacker from independently finding that witness through other means if sufficient information (like the anagram letters and the target hash) is available. For this specific Panagram game design, fully mitigating this offline brute-force attack using only the current cryptographic setup is challenging. While ZK concepts like "commitments" (using a commitment scheme to hide the `answer_double_hash` until a reveal phase) could be explored in more complex systems, they wouldn't directly stop someone from brute-forcing the word if they know the anagram letters and can eventually verify their guesses against a revealed hash. This particular bug related to anagram brute-forcing is acknowledged as a limitation of the current game design rather than a flaw in the ZK-SNARK implementation itself, and its resolution is outside the scope of the double-hashing fix. ## Key Learnings from Panagram Vulnerability Fixes Addressing the vulnerabilities in the Panagram ZK-SNARK project provides several important takeaways for developers working with zero-knowledge proofs and secure system design: * **Input Integrity is Paramount:** Public inputs to ZK circuits can be manipulated. Designs must ensure that private inputs cannot be trivially reconstructed or forged using public information. The double-hashing scheme implemented here adds a crucial layer of protection by requiring an additional computation within the circuit on private data. * **System-Wide Consistency:** When making cryptographic changes, such as modifying hashing schemes, it's vital to update all components of the system. This includes the Noir circuit, Solidity contracts and tests, and any off-chain scripts used for proof generation or interaction. * **Leverage Noir Dependencies:** Noir's package management allows for the inclusion of external libraries, such as `keccak256`, to implement standard cryptographic primitives directly within circuits. This simplifies development and promotes the use of well-tested components. * **Understand ZK-SNARK Limitations:** ZK-SNARKs are powerful tools for proving computational integrity and knowledge of secret information without revealing it. However, they do not inherently protect against all forms of attack. Offline brute-force attacks on the underlying secret are still possible if the search space is small and enough correlating public information is available. * **Iterative Security:** Security is an ongoing process. Identifying vulnerabilities, understanding their root causes, and implementing effective mitigations are key to building robust and trustworthy decentralized applications.
An illuminating dissection of Securing Proof Inputs: The Double Hashing Solution - Uncover a critical ZK-SNARK vulnerability that allows proof input manipulation and how a double-hashing strategy provides a robust fix. This lesson guides you through implementing this security enhancement across Noir circuits, Solidity, and supporting JavaScript.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025
Duration: 6min
Duration: 1h 11min
Duration: 2h 12min
Duration: 3h 19min
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025