On September 22nd, 2025, zkSecurity started a security audit of changes to Midnight’s proof system and circuits. The audit lasted two weeks with two consultants. We reviewed the midnight-zk repository at commit 8fe1bf4 and the midnight-trusted-setup repository at commit c2410f0.
This is zkSecurity’s third audit collaboration with Midnight Network. The previous audits of the circuits and proof system were conducted in November 2024 and March 2025.
The audit covered changes to the proof system in the midnight-zk repository, specifically:
- Updates to the PLONK proof system implementation in
proofs/src/plonk/, in particular the “trash argument” and committed instances
- Changes to circuits in the
circuits/ directory, including the new SHA-256 circuit implementation (sha256_varlen.rs and sha256_chip.rs)
- The
midnight-trusted-setup repository for the trusted setup ceremony
Committed instances is a feature based on the aPLONK paper that allows certain public inputs to be “committed” rather than provided in plaintext, enabling more compact proofs for specific use cases. In standard PLONK, all instance columns (public inputs) are visible to both the prover and verifier. The committed instances feature partitions instance columns into two categories: regular public instances and committed instances.
For committed instances, instead of providing the raw field element values, the prover supplies polynomial commitments to those values. The verifier receives only the commitments and the polynomial openings at specific query points needed for constraint verification. By committing to instance data rather than including it in plaintext, proofs can be significantly smaller, especially when instance columns contain large amounts of data.
The committed instances feature operates under the architectural constraint that the first instance columns by index are always the committed ones. The parameter nb_committed_instances determines this split: columns 0 through nb_committed_instances-1 are committed, while columns nb_committed_instances and above remain as regular public inputs.
During proof generation, the prover:
- Uses
commit_to_instances() to create polynomial commitments for the first N instance columns
- Provides these commitments to the verifier along with the regular public instances
- Includes polynomial openings in the proof for any committed instance values that are queried by circuit constraints
During verification, the verifier:
- Reads committed instance evaluations from the transcript for queries where
column.index() < nb_committed_instances
- Uses regular public instance values for queries where
column.index() >= nb_committed_instances
This feature is particularly valuable in proof aggregation scenarios, such as Midnight’s light aggregator, where MSM (Multi-Scalar Multiplication) scalars are committed to keep the aggregated proof compact while still proving their correctness through the underlying constraint system.
The trash argument is a technique that allows selectors to not increase the polynomial degree of the constraint system.
The classical approach of selectively enabling constraints in PLONK is by using a multiplicative selector, and enforcing
where is the selector polynomial and is the constraint polynomial in witness columns . In rows where , the equation is satisfied independently of , so the constraint is inactive. However, note that if the constraint polynomial has degree , multiplying the selector pushes the total degree of this equation to .
The trash argument introduces a trash column and enforces the following equation (in the case of a single constraint) using an additive selector :
If , we get just like in the multiplicative case. If , the equation becomes , which can be trivially satisfied by the prover regardless of since is not constrained in any other way, so again this deactivates the constraint. However, the degree of the additive version is only , so this method reduces the degree for constraints of degree . This comes at the cost of introducing one additional witness column that has to be committed to and opened.
In the full protocol, to support selecting multiple constraints at once, a new trash challenge is introduced, and all constraints under a given selector are batched using :
with the same reasoning as before as to why deactivates all constraints at once.
We remark that has to be computed after the challenge is known and can therefore not enter the transcript up to this point, which is fine because picking does not give the prover any additional power when .
In Midnight’s PLONK implementation, additive selectors are used in the Poseidon chip to allow partial Poseidon rounds to compute 5th powers directly, without intermediate witnesses, and without increasing the maximum degree of 5 (which would require a larger SRS). Partial Poseidon rounds are done with multiple S-box applications per row, so introducing intermediate witnesses would be more expensive than the single extra column added by the trash argument.
The trusted setup ceremony is a critical process for generating the Structured Reference String (SRS) used in KZG polynomial commitments within the PLONK proof system. The ceremony involves multiple participants who each contribute entropy to generate cryptographic parameters that must remain secret to maintain the security of the proof system.
The SRS consists of powers of a secret value in both and groups: and , where and are generators of the respective groups. The secret must be destroyed after the ceremony - if an attacker learns , they can forge proofs by creating fake polynomial commitments.
The ceremony follows a sequential process where each participant:
- Downloads the current SRS state containing powers
- Generates fresh randomness and multiplies the existing string:
- Creates a proof that the update was performed correctly using their secret
- Publishes the updated SRS for the next participant, then destroys
Each participant effectively compounds the secret: if the ceremony starts with and participants contribute , the final secret becomes . The security model requires that at least one participant remains honest and destroys their secrets.
The ceremony is rooted in Filecoin’s challenge19 SRS (stored locally as filecoin_srs_g1_point) to prevent participants from restarting the chain. During contribution generation, the CLI mixes fresh OS entropy with keystroke input, rerandomizes the SRS, and emits a Schnorr-style update proof that cryptographically ties the original to the new .
The ceremony tools provide two critical verification functions:
- Structural verification: Validates that the SRS contains the correct mathematical structure (proper powers of the secret) and expected number of group elements
- Chain verification: Links each participant’s proof to their SRS update, ensuring the sequence of contributions is cryptographically valid and rooted to the Filecoin anchor
Key functions in the trusted setup include:
- SRS structure verification: Validates the mathematical structure and size of SRS files
- Chain verification: Confirms the sequence of participant contributions and their proofs
- Contribution generation: Allows participants to add their entropy and generate update proofs
- Final parameter extraction: Converts the ceremony output into the format required by the proof system
The resulting SRS enables the prover to commit to polynomials and the verifier to check these commitments without learning the underlying polynomial coefficients, forming the foundation of the zero-knowledge proof system’s hiding and binding properties.
We audited a SHA-256 circuit implementation that was newly rewritten from scratch. The logic is split into two parts:
- The core
Sha256Chip, that lays out a series of custom gates to implement the SHA-256 message schedule and compression rounds
- A higher-level
VarLenSha256Gadget that handles variable-length inputs using a dynamic padding circuit and a loop of conditional state updates
The core chip is based on the halo2 book’s SHA2 design with its idea of a plain-spreaded table:
For a given bit length, a lookup table contains the bit length as a “tag”, all possible values of that bit length, as well as their “spreaded” versions, which interleave a zero bit between each original bit. For example, the 4-bit portion of the table would start with the following entries:
Tag | Value (4 bit) | Spreaded (8 bit)
----|---------------|----------------
4 | 0b0000 | 0b00000000
4 | 0b0001 | 0b00000001
4 | 0b0010 | 0b00000100
4 | 0b0011 | 0b00000101
...
The interesting property of this table is that it allows efficient constraints for common bitwise operations like XOR. In addition, it serves as a range check table, and it even supports highly efficient representations for special bit operations used in SHA2, like MAJ(X,Y,Z) = (X & Y) ^ (X & Z) ^ (Y & Z).
Furthermore, note that the table size is only linear in the supported bit length, compared to the more naive approach of a 2-to-1 XOR table which is quadratic.
To see how this table is used, let us denote the spreaded version of a value X by ~X. Note that when we add two spreaded values, using plain field addition, bitwise sums only overflow to the spreaded zero bits and never interfere with each other. So, Z = X ⊕ Y can be enforced as ~Z + 2 * ~W = ~X + ~Y for some spreaded value W. This only needs 4 plain-spreaded lookups (for X, Y, Z, and W) and a single addition constraint. The result Z is already guaranteed to be in the target range.
To minimize constraints, several additional tricks are used in Midnight’s implementation:
- Range-checked circuit data types carry along both normal and spreaded representations, so that spread lookups can be reused
- There are custom data types for specific ways of splitting a 32-bit integer into smaller chunks, such as 11-11-10, 10-9-11-2 or 7-12-2-5-6. These are optimized for the particular bit rotations and shifts used in SHA2. The observation is that constructing these splits already acts as a range check, constraining a previous operation such as a modular addition. The 32-bit range check is actually the main cost of such an operation. Since using an alternative split does not add much overhead, we get the cheap rotation essentially for free.
Another interesting design choice can be found in the variable-length gadget: It uses a variable-length array type, implemented as a fixed-length array plus a field element that carries the actual length. Fixed-length arrays
- already come chunked into 64-byte blocks suitable for SHA2;
- contain padding chunks at the beginning of the array, whose values are unconstrained;
- then contain full chunks of actual data;
- contain a single final chunk that mixes actual data and unconstrained padding.
This design means that the circuit does not have to select the correct chunk to apply padding to; it always comes at the very end. At the same time, a variable number of full chunks is accounted for by making full-chunk hash updates conditional on the actual length. The most complex part is applying SHA2 padding to the final chunk (which optionally results in one additional chunk); however, that part is light on constraints since it only operates on a single chunk of 64 field elements.
In effect, variable-length hashing has practically no overhead compared to hashing a fixed, maximum-length input.
Our audit of the new SHA2 gadget did not lead to any issues discovered, and we have high confidence in its correctness.
During this audit engagement, we carefully audited all circuits in scope through manual code review, analyzing each circuit for potential underconstraints (missing or insufficient constraints that could allow invalid proofs), overconstraints (unnecessarily restrictive constraints that could reject valid proofs) and functional correctness issues (incorrect implementation of the intended cryptographic or arithmetic operations). This manual review included examining constraint equations, witness assignments, lookup table configurations, and the interaction between different circuit components to ensure the implementations correctly and securely realize their specifications.
In addition to manual auditing, we used three complementary semi-automatic security testing scripts to provide defense-in-depth verification:
- A static analysis script performing event-based dataflow analysis on Rust source code to detect witnesses assigned but never used in constraints, missing equality constraints, and forgotten range checks;
- A structural analysis ensuring every circuit cell participates in at least one constraint (gate, lookup, or copy).
- A dynamic analysis script that mutates witness values and verifies the circuit rejects invalid proofs, with mutation strategies including constant, nonlinear, coefficient errors, division and boolean gaps to catch a broad class of constraint vulnerabilities.
In particular, for (3), we added a way to mutate particular advice cells by region and row/column index, after synthesizing the circuit. This also turned out to be useful when confirming issues we found during manual review, as we could easily build proof of concepts of attacks that involve a malicious prover changing the witness logic.
As a strategic recommendation, we suggest adding this mutation capability as a first-class feature of the circuit testing framework. The current code base features functional correctness tests, but no adversarial tests where witnesses take values different than the automatically computed honest-prover values. We recommend adding such tests, for example, to check against regressions of the issues we found during this audit.
A second recommendation also relates to testing. Currently, the MockProver used in tests can check that constraints in the circuit are satisfied. However, constraints related to the trash argument/additive selectors are currently not checked. This causes a gap in tests for circuits like Poseidon, that rely on the trash argument. We recommend to extend the MockProver to also check trash argument constraints.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.