# Midnight Circuits and Proof System Follow-up Audit

- **Client**: Midnight
- **Date**: October 29th, 2025
- **Tags**: Halo2, PLONK, KZG, Poseidon, SHA-256, Trusted Setup, Midnight, Circuits

## Introduction

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](https://github.com/midnightntwrk/midnight-zk) repository at commit [`8fe1bf4`](https://github.com/midnightntwrk/midnight-zk/tree/8fe1bf4) and the [midnight-trusted-setup](https://github.com/midnightntwrk/midnight-trusted-setup) repository at commit [`c2410f0`](https://github.com/midnightntwrk/midnight-trusted-setup/tree/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](../midnight-circuits-1/) and [March 2025](../midnight-circuits-2/).

### Scope

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

## Background

### Committed instances

Committed instances is a feature based on the [aPLONK paper](https://eprint.iacr.org/2022/1352) 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 $N$ 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:

1. Uses `commit_to_instances()` to create polynomial commitments for the first N instance columns
2. Provides these commitments to the verifier along with the regular public instances
3. 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.

### Trash Argument

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

$$
s \cdot C(\vec{x}) = 0
$$

where $s$ is the selector polynomial and $C(\vec{x})$ is the constraint polynomial in witness columns $\vec{x}$. In rows where $s = 0$, the equation is satisfied independently of $\vec{x}$, so the constraint is inactive. However, note that if the constraint polynomial $C(\vec{x})$ has degree $d$, multiplying the selector pushes the total degree of this equation to $d + 1$.

The trash argument introduces a _trash column_ $t$ and enforces the following equation (in the case of a single constraint) using an _additive_ selector $s$:

$$
C(\vec{x}) - (1 - s) \cdot t = 0
$$

If $s = 1$, we get $C(\vec{x}) = 0$ just like in the multiplicative case. If $s = 0$, the equation becomes $C(\vec{x}) = t$, which can be trivially satisfied by the prover regardless of $\vec{x}$ since $t$ is not constrained in any other way, so again this deactivates the constraint. However, the degree of the additive version is only $\max(d, 2)$, so this method reduces the degree for constraints of degree $d \geq 2$. This comes at the cost of introducing one additional witness column $t$ that has to be committed to and opened.

In the full protocol, to support selecting multiple constraints at once, a new _trash challenge_ $\tau$ is introduced, and all constraints under a given selector $s$ are batched using $\tau$:

$$
\sum_i \tau^i C_i(\vec{x}) = (1 - s) \cdot t,
$$

with the same reasoning as before as to why $s = 0$ deactivates all constraints at once.

We remark that $t$ has to be computed after the challenge $\tau$ is known and can therefore not enter the transcript up to this point, which is fine because picking $t$ does not give the prover any additional power when $s = 1$.

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.

### Trusted setup

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 $\tau$ in both $\mathbb{G}_1$ and $\mathbb{G}_2$ groups: $\{g^{\tau^i}\}_{i=0}^{n-1}$ and $\{h^{\tau^i}\}_{i=0}^{1}$, where $g$ and $h$ are generators of the respective groups. The secret $\tau$ must be destroyed after the ceremony - if an attacker learns $\tau$, they can forge proofs by creating fake polynomial commitments.

The ceremony follows a sequential process where each participant:

1. Downloads the current SRS state containing powers $\{g^{\tau^i}\}_{i=0}^{n-1}$
2. Generates fresh randomness $\nu$ and multiplies the existing string: $\{g^{(\tau \cdot \nu)^i}\}_{i=0}^{n-1}$
3. Creates a proof that the update was performed correctly using their secret $\nu$
4. Publishes the updated SRS for the next participant, then destroys $\nu$

Each participant effectively compounds the secret: if the ceremony starts with $\tau_0$ and participants contribute $\nu_1, \nu_2, \ldots, \nu_k$, the final secret becomes $\tau_{\text{final}} = \tau_0 \cdot \nu_1 \cdot \nu_2 \cdots \nu_k$. 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 $[\tau]_1$ to the new $[\tau']_1$.

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.

### SHA-256 gadget

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](https://zcash.github.io/halo2/design/gadgets/sha256/table16.html) 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.

## Methodology and Recommendations

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:

1. 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;
2. A structural analysis ensuring every circuit cell participates in at least one constraint (gate, lookup, or copy).
3. 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.

## Findings

### ECC Chip Outputs Ambiguous y Coordinate

- **Severity**: High
- **Location**: ecc_chip.rs

**Description**. The `as_public_input()` helper in the ECC chip keeps only the lowest limb of the $y$-coordinate to resolve the ambiguity between the two curve points $(x, y)$ and $(x, -y)$.

```rust
// From y we only keep one limb, since it is enough to resolve the +- ambiguity.
let mut pis = [
    self.base_field_chip.as_public_input(layouter, &p.x)?.as_slice(),
    &self.base_field_chip.as_public_input(layouter, &p.y)?[..1],
]
.concat();
```

However, the chip accepts non-canonical base field encodings. The limbs may represent $y + m$ where $m$ is the foreign field modulus. `base_field_chip.as_public_input()` calls `normalize()` but that only affects field elements where the _individual_ limbs are not within range.

If $y = k \cdot \text{BASE}/2$, the lowest limb of $m + y$ equals that of $m - y$, since

$$
(m + y) - (m - y) \equiv 2y \equiv 0 \mod{\text{BASE}}
$$

The lowest limb therefore cannot distinguish between $y$ and $-y$.

**Impact**. The public input does not disambiguate the curve point: the prover can use $-P$ for witnesses while the output appears as $P$. This is a soundness issue and could have severe consequences for applications.

**Recommendation**. There are two possible routes to resolve this:

- Emit the full `y` coordinate, or at least enough public information to uniquely identify the point. For example, as mentioned by Midnight developers, coordinate limbs might be packed into fewer native field elements.
- Alternatively, constrain the $y$ coordinate to be canonical. When that is done, even the lowest bit is enough to distinguish between $y$ and $m - y$, since adding the odd number $m$ flips the parity.

**Client Response**. The issue was fixed in [commit 1e0e061](https://github.com/midnightntwrk/midnight-zk/commit/1e0e061bdd2dd93cb44585abffe524fa6d88e2eb) by returning the full $y$ coordinate as public input.

### Typo in Field Chip Causing inv(0) = 0

- **Severity**: High
- **Location**: circuits/src/field/foreign/field_chip.rs

**Description**. Line 920 in `circuits/src/field/foreign/field_chip.rs` contains a typo that causes the inverse of zero to return zero instead of failing as expected. The line incorrectly assigns the fixed value zero when it should assign one:

```rust
// Line 920 (typo)
let one: AssignedField<F, K, P> = self.assign_fixed(layouter, K::ZERO)?;
```

When it should use `K::ONE` to match the correct pattern established in line 928:

```rust
// Line 928 (correct)
let one = self.assign_fixed(layouter, K::ONE)?;
```

The consequence is that line 922's check `if x == &one` compares against zero instead of one, causing `inv(0)` to return zero rather than attempting the inversion and failing at line 929.

**Impact**. This typo causes `inv(0) = 0` behavior, which violates the expected trait contract for field inversion operations. According to standard field arithmetic and the native version implementation, attempting to invert zero should fail rather than return zero. This could lead to unexpected behavior in circuits that rely on proper field inversion semantics, potentially masking bugs where zero values are incorrectly passed to inversion operations.

**Recommendation**. Change line 920 from `K::ZERO` to `K::ONE` to fix the typo and ensure proper field inversion behavior that matches the trait contract and native implementation.

**Client Response**. Client has acknowledged this issue and fixed it in https://github.com/midnightntwrk/midnight-zk/pull/125.

### Equality Gadgets Are Underconstrained

- **Severity**: High
- **Location**: native_chip.rs

**Description**. The core `is_equal()` and `is_equal_to_fixed()` gadgets in the native chip use `add_and_double_mul()` as if it enforced its result to be zero:

```rust
// (ii) enforced as res * x - res * y = 0.
self.add_and_double_mul(
    layouter,
    (F::ZERO, &res),
    (F::ZERO, x),
    (F::ZERO, y),
    F::ZERO,
    (F::ONE, -F::ONE),
)?;
```

However, `add_and_double_mul()` only _returns_ of the arithmetic combination, and never constrains it to be zero. Therefore, the intended constraint that enforces the `is_equal` result to be zero when $x \ne y$ is ineffective. A malicious prover can output `true` or even non-Boolean outputs even though the inputs are unequal.

**Impact**. Equality checks throughout the code base can be forged, rendering many typical circuits severely underconstrained.

**Recommendation**. Actually constrain `(x - y) * res` to be zero.

**Client Response**. The issue was fixed in [PR #117](https://github.com/midnightntwrk/midnight-zk/pull/117) by passing the output of `add_and_double_mul()` to `assert_zero()` to enforce the zero constraint.

### Variable-Length Poseidon Miscomputes Hashes For Short Inputs

- **Severity**: High
- **Location**: poseidon_varlen.rs

**Description**. The `poseidon_varlen` gadget returns incorrect hash outputs when called with input slices of length 1 or 2.

For background, Poseidon is instantiated with a rate of 2 and therefore processes inputs in chunks of size 2.

Variable length arrays are represented by an array padded with unconstrained full chunks at the beginning. Therefore, when computing the hash, we have to keep track whether the current chunk already belongs to the actual array content. The Poseidon code tracks this with a boolean variable `updating`:

```rust
// Flag that will signal when the hash input starts and chunks need to be
// effectively processed and update the state.
let mut updating: AssignedBit<F> = self.native_gadget.assign_fixed(layouter, false)?;

// [ZKSECURITY] ...

// Conditional update loop. Stops 1 chunk before the end.
for i in 0..(MAX_LEN / RATE) - 1 {
    // Determines when we have arrived at the first chunk of input.
    let b = ng.is_equal_to_fixed(
        layouter,
        &rounded_len,
        F::from((MAX_LEN - (i * RATE)) as u64),
    )?;

    updating = ng.xor(layouter, &[b, updating])?;
    register = self.cond_update(layouter, &register, chunk, &updating)?;

    chunk = chunk_iter.next().expect("One more chunk.");
}

// Modify last chunk with the appropriate padding if necessary.
let chunk = self.pad_last_chunk(layouter, chunk, len, &offset)?;
// [ZKSECURITY] `updating` is not recomputed for the final chunk!
// [ZKSECURITY] => if rounded_len = 2, we are incorrectly missing this update
register = self.cond_update(layouter, &register, &chunk, &updating)?;
```

The problem is that for the last chunk, we are not correctly recomputing `updating`. If it was `false` throughout the loop, the final conditional update is not performed, even if `rounded_len` is 2 and the final chunk has actual content. Instead of being hashed, the single message chunk is simply ignored.

Existing tests for variable-length Poseidon were not exercising the case of input lengths 1 and 2.

**Impact**. Any consumer that hashes a single- or double-element input through `poseidon_varlen` will derive an incorrect digest. Even worse, the digest does not depend on the hashed content, which breaks all properties expected of a hash function and makes essentially every use of this gadget which does not control the input length insecure.

**Recommendation**. The following patch, proposed by Midnight developers, is enough to fix the issue:

```diff
let chunk = self.pad_last_chunk(layouter, chunk, len, &offset)?;
+ let updating = {
+   let b = ng.is_equal_to_fixed(layouter, &rounded_len, F::from(RATE as u64))?;
+   ng.xor(layouter, &[b, updating])?
+ };
register = self.cond_update(layouter, &register, &chunk, &updating)?;
```

**Client Response**. The issue was fixed in [commit bb8f118](https://github.com/midnightntwrk/midnight-zk/commit/bb8f11814d972d56eb4aeeb7903ef1fe28d75831) with the recommended patch.

### Integer Overflow in Blinding Factors Calculation

- **Severity**: Low
- **Location**: proofs/src/plonk/circuit.rs

**Description**. The `blinding_factors()` function in line 2355 of `proofs/src/plonk/circuit.rs` performs addition that can overflow when later cast to `i32`:

```rust
let factors = factors + self.trashcans.len();
```

This value is subsequently used in multiple locations where it gets cast to `i32`, such as:

- `proofs/src/plonk/permutation/verifier.rs:221`
- `proofs/src/plonk/evaluation.rs:358`
- `proofs/src/plonk/prover.rs:228,248`

The cast occurs in expressions like:

```rust
let x_last = vk.domain.rotate_omega(x, Rotation(-((blinding_factors + 1) as i32)));
```

When `blinding_factors` exceeds `i32::MAX` ($2^{31}-1$), the cast wraps around, turning a large positive number into a negative value. This negative value is then multiplied by -1, producing an incorrect positive rotation amount and causing polynomial evaluation at wrong points in the verifier.

Consider this scenario:

```rust
let i32_max = i32::MAX as usize;
let base_factors = 10;
let massive_trash_count = i32_max + 100; // Force overflow
let total_blinding_factors = base_factors + massive_trash_count + 1;

// This wraps: 2147483757 as i32 = -2147483539
let rotated = -((total_blinding_factors) as i32);
println!("Expected negative, got positive: {}", rotated); // Shows: 2147483539
```

**Impact**. This could potentially break soundness by allowing malicious provers to exploit the incorrect rotation calculations. While this was theoretically possible in previously audited code via `num_advice_queries`, it was impractical due to $O(n^2)$ complexity. The introduction of trash arguments makes this more feasible with $O(n)$ complexity, as an attacker now needs $2^{31}$ trash arguments to trigger the overflow.

Although this would require several GB of RAM and produce multi-GB proofs that might exhaust the verifier before soundness exploitation, it may succeed on some systems. The verification logic does not prevent large proofs or excessive trash arguments.

**Recommendation**. Validate that `factors <= i32::MAX` in the `blinding_factors()` function and fail gracefully before returning to avoid the casting issue:

```rust
fn blinding_factors(&self) -> Result<usize, Error> {
    let factors = factors + self.trashcans.len();
    if factors > i32::MAX as usize {
        return Err(Error::BlindingFactorsOverflow);
    }
    Ok(factors)
}
```

**Client Response**. Client has acknowledged this issue and fixed it in https://github.com/midnightntwrk/midnight-zk/pull/125.

### Prover-Verifier Logic Mismatch for Committed Instance Queries

- **Severity**: Low
- **Location**: proofs/src/plonk/prover.rs

**Description**. The prover and verifier use different logic to determine which PCS queries belong to committed instance columns. The prover uses positional slicing (`.take(nb_committed_instances)` in `prover.rs:774`) to select the first N queries regardless of their column association:

```rust
pk.vk.cs.instance_queries.iter().take(nb_committed_instances).map(...)
```

While the verifier uses column-based filtering (`column.index() < nb_committed_instances` in `verifier.rs:251`) to expect all queries from the first $N$ instance columns.

The committed instances feature operates under the architectural constraint that the first $N$ instance columns by index are always the committed ones. However, when a committed instance column is queried multiple times (e.g., at different rotations like `Rotation::cur()` and `Rotation::next()`), or when query ordering doesn't align with column indices due to interleaved advice/fixed queries, the prover omits some committed instance openings from the multi-opening batch while the verifier still expects them.

**Impact**. This causes a completeness issue where verification fails with an "Opening" error, making the committed instances feature fail for circuits that query committed columns at multiple rotations or mix advice, fixed, and instance queries in gates. While this doesn't happen in the aggregator, it could affect other circuits with more complex query patterns.

**Recommendation**. Align the prover logic with the verifier's column-based approach. Instead of using positional slicing, filter queries by column index:

```rust
pk.vk.cs.instance_queries.iter()
    .filter(|&(column, _)| column.index() < nb_committed_instances)
    .map(...)
```

This ensures that all queries belonging to committed instance columns are properly included in the multi-opening batch, regardless of their position in the query list or rotation patterns.

**Client Response**. The issue was fixed in [commit e97f1e1](https://github.com/midnightntwrk/midnight-zk/commit/e97f1e1738cf8a8c5e833ca9a48a2f79d8931802).

### SRS Truncation Vulnerability in Trusted Setup Ceremony

- **Severity**: Low
- **Location**: midnight-trusted-setup

**Description**. The trusted setup ceremony lacks validation of the SRS file size when reading, verifying structure, or updating the ceremony. This allows an attacker to truncate $G_1$ points from the SRS and still pass all validations. The truncated SRS will then be propagated to subsequent participants, who will update the compromised SRS and continue passing validations.

A proof-of-concept demonstrates the vulnerability:

1. Downloaded the latest SRS file (3,221,225,856 bytes)
2. Truncated the file by removing the last 1000 $G_1$ points (3,221,129,856 bytes)
3. File size reduction was only 96KB, making the attack difficult to detect
4. Both `verify-structure` and `verify-chain` operations passed on the truncated file

```bash
# Original SRS
SHA256 (powers_of_tau.1) = a8a9c33e7a974399b38770f4ee61a9403ce341005f6190b6a25af0bfa3b55d01

# Truncated SRS (1000 G_1 points removed)
SHA256 (powers_truncated) = c9035797846168011fc0cfcce1fd9047149664b5dbd753a3f59ef463daef8f01

# Verification still passes
./target/release/srs_utils powers_truncated verify-chain
Verifying the chain of update proofs...
The chain of update proofs is correct!
```

The size difference is minimal:

- Original: 3,221,225,856 bytes
- Truncated: 3,221,129,856 bytes (96KB smaller)

**Impact**. This vulnerability enables stealthy attacks where small truncations are unlikely to be noticed due to minimal file size changes. Once a truncated SRS enters the ceremony chain, it permanently corrupts the entire process as all subsequent participants will unknowingly work with the compromised SRS. The truncation will only be discovered when circuits requiring the missing high-order powers are used, potentially long after the ceremony concludes, requiring a complete restart of the trusted setup ceremony.

**Recommendation**. Implement comprehensive SRS size validation in the ceremony tools by verifying that the SRS file has the expected exact size before processing and checking that the SRS contains the expected number of $G_1$ and $G_2$ points. Add size checks in critical operations including before reading SRS files, during structure verification, before ceremony updates, and in chain validation processes. Additionally, implement file server-side validation if the SRS is distributed through a centralized mechanism to prevent corrupted files from entering the ceremony chain.

**Client Response**. The client has acknowledged the issue and has fixed it in https://github.com/midnightntwrk/midnight-trusted-setup/pull/69/.

### Verifier Panics on Empty Committed Instances

- **Severity**: Low
- **Location**: proofs/src/plonk/verifier.rs

**Description**. The PLONK verifier assumes that the `committed_instances` vector is non-empty, causing a panic when called with empty instance vectors. In lines 40 and 199 of `proofs/src/plonk/verifier.rs`, the code performs:

```rust
let nb_committed_instances = committed_instances[0].len();
```

This directly accesses the first element of `committed_instances` without checking if the vector is empty, leading to an index out of bounds panic.

The following test demonstrates the vulnerability:

```rust
let empty_instances: &[&[&[Fr]]] = &[];

let _result = prepare_plonk_proof::<Fr, Scheme, _>(
    &vk,
    #[cfg(feature = "committed-instances")]
    &[], // empty committed instances
    empty_instances,
    &mut transcript,
);
```

This results in:

```
thread 'test_empty_instances_vulnerability' panicked at midnight-zk/proofs/src/plonk/verifier.rs:40:34:
index out of bounds: the len is 0 but the index is 0
```

**Impact**. This creates a potential denial-of-service (DoS) vulnerability where malicious actors can crash the verifier by providing empty committed instances. This could disrupt proof verification processes and affect system availability.

**Recommendation**. Check if `committed_instances` is empty before accessing its first element. The verifier should fail gracefully if empty instances are not expected, or default to 0 if that behavior is appropriate for the protocol. Consider adding bounds checking and graceful error handling for this scenario.

**Client Response**. Client has acknowledged this issue and fixed it in https://github.com/midnightntwrk/midnight-zk/pull/125.

### Committed Instance Split Not Enforced at Protocol Level

- **Severity**: Informational
- **Location**: proofs/src/plonk/

**Description**. The proving system does not enforce which instance columns should be committed versus public at the protocol level. The prover specifies the split between committed and "plain" instance columns at proof generation time, and the core verification API accepts whatever split the prover supplies. This allows the prover to arbitrarily "promote" instance columns (marking them as committed and supplying only their commitment) or "demote" them (streaming them in the clear).

Both proof types verify successfully at the protocol level because the commitment openings still match and the algebraic checks remain intact. A proof-of-concept demonstrates:

- _Promotion_: Taking a normally public instance column, committing to it, and providing only the commitment to the verifier
- _Demotion_: Taking a normally committed instance column and providing its values directly as public instances

The proving system API cannot reject malformed splits and any validation of the expected committed/public boundary must be implemented in external verifier logic outside the core proving system.

**Impact**. While this doesn't create soundness or completeness failures at the cryptographic level, it places the burden of enforcing the correct instance split on application-level verifier logic. If the external verifier logic doesn't validate the expected split, provers can manipulate which values are committed versus public. This could lead to unexpected behavior in applications that assume certain values will be available for off-circuit validation or that rely on specific privacy guarantees.

**Recommendation**. Consider whether the current design is intentional. If applications require enforcement of specific committed/public splits, this must be implemented in external verifier logic rather than relying on the proving system. Alternatively, consider encoding the expected number of committed instances in the verification key or circuit definition to provide protocol-level enforcement of the commitment scheme interface.

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
