critical · 9.3CVE-2026-54496Jul 6, 2026

CVE-2026-54496: halo2_gadgets Variable-Base Scalar Multiplication Under-Constrained Base Point

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A missing copy constraint in Zcash's halo2_gadgets ECC gadget let a malicious prover substitute an arbitrary base point into the Orchard Action circuit, bypassing the integrity check that ties a shiel

Packagezebrad
Ecosystemrust
Affected<= 4.5.1
Fixed in5.0.0

The problem

The variable-base scalar multiplication gadget in `halo2_gadgets/src/ecc/chip/mul/incomplete.rs` wrote the base point coordinates `(x_p, y_p)` into the circuit using `assign_advice()` at the first iteration of the double-and-add loop (L309-310, commit `32a87582`).

That call places a witness value in the constraint system but does not tie it to any previously committed cell.

The existing `q_mul_2` selector kept `x_p`/`y_p` uniform across loop rows, but it anchored them only to the first row's unconstrained witness. A malicious prover could therefore set `(x_p, y_p)` to any point they chose. The gadget enforces the Orchard diversified-address-integrity condition `pk_d = [ivk] g_d`; with a free base, an attacker could pick a fake nullifier key `nk`, derive a wrong `ivk` from it, then solve for the base point that makes `[ivk] g_d` equal the target `pk_d`, defeating the check entirely.

The result was undetectable double-spending or theft of funds protected only by an incoming viewing key.

Proof of concept

A working proof-of-concept for CVE-2026-54496 in zebrad, with the exact payload below.

rust
// Vulnerable path: assign_advice does NOT create a copy constraint.
// Attacker controls (x_p, y_p) freely in the first loop iteration.
//
// incomplete.rs (pre-fix, ~L309-310)
region.assign_advice(
    || "x_p",
    self.double_and_add.x_p,
    row + offset,       // first iteration row
    || x_p,             // attacker sets this to a crafted point
)?;
region.assign_advice(
    || "y_p",
    self.y_p,
    row + offset,
    || y_p,             // attacker sets this to a crafted point
)?;

// Exploit algebra (pseudocode):
// 1. Pick any fake nk (not the note's real nullifier key).
// 2. Derive wrong_ivk = Commit_ivk(Extract(ak), nk)  -- circuit forces this honestly.
// 3. Solve for crafted_base such that [wrong_ivk] crafted_base == pk_d.
//    (Feasible because ECDLP is over a known-order group; prover knows wrong_ivk.)
// 4. Set x_p = crafted_base.x, y_p = crafted_base.y in the witness.
// 5. q_mul_2 propagates the chosen base across all loop rows -> proof verifies.
// 6. nf = Nullifier(nk, ...) produces a fresh, valid-looking nullifier for the
//    same note, enabling a repeat spend.

CWE-345 (Insufficient Verification of Data Authenticity). In halo2's PLONKish constraint system, `assign_advice()` places a witness value into a cell but creates no permutation constraint linking that cell to any other. The `q_mul_2` custom gate constrained consistency across loop iterations but had no anchor to the actual base point passed in by the caller.

Replacing the two `assign_advice()` calls with `copy_advice()` inserts a copy constraint (a permutation argument) that forces the first-row cell to equal the cell holding the real base, which `q_mul_2` then propagates transitively to every row of the loop. Because changing the circuit changes the verifying key, the fix required a hard fork (NU6.2) to rotate the pinned key on-chain.

The fix

Upgrade to `halo2_gadgets` >= 0.5.0, `orchard` >= 0.14.0, `zcash_primitives` >= 0.28.0, `zebrad` >= 5.0.0 (NU6.2, activated block 3,364,600 on 2026-06-03), or `zcashd` >= 6.20.0. An interim soft-fork mitigation that rejected all Orchard-containing transactions shipped in `zebrad` 4.5.3 (block 3,363,426).

Downstream projects using `assign_advice()` to supply a value that must equal an already-committed cell should audit those sites and replace them with `copy_advice()` or an explicit equality constraint.

Reported by Taylor Hornby (independent, contracted by Shielded Labs).

References: [1][2]

Related research