Introduction
In this post we just want to cover a fun bug we found: fun because the math itself is interesting, and fun because it breaks just the right way. The bug is issue #44 in Inferno, which is a Rust implementation of the Limbo MPC-in-the-head zero-knowledge argument, which is an improvement upon the KKW scheme. The bug in the implementation is one character long, surprisingly common, usually not game ending, but over binary extension fields everything breaks in just the right way…
Limbo
Limbo proves satisfiability of an arithmetic circuit over any field , usually, applied to small fields, as in Inferno, . So every wire carries a binary value, addition is XOR and multiplication is bitwise AND. The advantage is that you don’t pay “embedding into a large field for soundness”-overhead for each of the bits you want to operate on, which means very good concrete computational and communication costs. The “disadvantage” of Limbo is that of classical MPC-in-the-head: communication/verifier cost is linear.
The way that things work in Limbo is that the prover commits to every wire value of evaluated on via additive secret sharings between virtual MPC parties, run “in the prover’s head”; the verifier later opens a subset of the parties’ views and checks consistency.
Linear gates (addition, scalar multiplication) act locally on shares, so the parties evaluate them for free with no additional commitments. Multiplication gates are the hard part: shares of and do not combine locally into a sharing of . So for every multiplication gate the prover additionally commits to a sharing of the claimed output , and the verifier has to check that the prover did not lie:
Where are the input wires to the -th multiplication gate (themselves linear combinations of earlier wires, hence already shared) and is the freshly committed claim. Every multiplication gate contributes a gate error:
The prover is honest iff every . Checking each gate individually is expensive, so Limbo collapses all gate errors into a single check. The errors live in , but the verifier picks a challenge from a large extension field , in Inferno we have , and asks the prover to convince him that:
The errors become the coefficients of a polynomial , and is a uniformly random evaluation point in . If the prover is honest, is the zero polynomial and trivially. If even one is non-zero, is a non-zero polynomial of degree at most , so by Schwartz-Zippel it vanishes at with probability at most . This is Π-MultCheck from Limbo, §4.1, and the rest of the protocol is built on top of it.
Note: the field in Inferno is not large enough for computational soundness, this is solved by simply repeating the check multiple times in parallel when compiling the protocol with Fiat-Shamir.
The Code
Here is the relevant snippet of round.rs which implements the evaluation of :
pub(crate) fn round1<S: LinearSharing<F, N>, F: FiniteField, const N: usize>(
round0: Round<S::SelfWithPrimeField>,
mults: &[S::SelfWithPrimeField],
challenge: F,
) -> Round<S> {
let mut sum = S::default();
let mut xs = vec![S::default(); round0.xs.len()];
let mut ys = vec![S::default(); round0.ys.len()];
let mut r = challenge;
for (i, ((x, y), z)) in round0
.xs
.iter()
.zip(round0.ys.iter())
.zip(mults.iter())
.enumerate()
{
sum += S::multiply_by_superfield(z, r);
xs[i] = S::multiply_by_superfield(x, r);
ys[i] = S::lift_into_superfield(y);
r *= r;
}
Round { xs, ys, z: Some(sum) }
}
The intent is for r to take the values , the intention is to compute:
The triple is the inner-product instance that the rest of the protocol verifies: downstream the verifier checks that . With the intended values, completeness is automatic when the prover is honest (). Soundness reduces to evaluating at :
So checking is exactly checking .
The Bug
The problem is that, instead of r *= challenge, the code uses r *= r, maybe you already spotted this.
This is repeated squaring, not successive powers, so r actually takes the values:
and the check the verifier ends up performing is therefore:
Where is the error at multiplication gate .
Observe that does not have degree , but instead has degree ; which is huge! So the usual soundness argument, based on the Schwartz–Zippel lemma:
Yields nothing when , in which case the right side is greater than for Inferno because . Okay, so theoretically broken, but is this just a proof gap? It turns out no, to see why, we need to look at the Frobenius endomorphism…
The Frobenius Endomorphism
The Frobenius endomorphism on is the map:
Its -th power is repeated squaring, which is the same as raising to the -th power:
Linearity.
Observe that is -linear, because of the “Freshman’s dream”:
Because in characteristic 2, and multiplication by scalars in also works:
Composition of linear maps is also -linear, so every power is also linear. Furthermore, any linear combination of linear maps is of course also linear, so:
Where is also a linear map from to .
Periodicity.
Finally, on we have , because for every element of :
Hence:
Only distinct powers exist as functions : .
The Exploit
All of this spells doom…
The obvious issue is that is a combination of these powers-of-Frobenius, so if e.g.
Then and have the same evaluation for every since: so for every . Observe that in and the simplest attack is therefore to simply “flip” the results of the two multiplications: pick any two multiplication gates with indices but such that , and set both errors to :
All this requires is a circuit with more than multiplication gates, which is pretty certain to be the case, not many useful circuits with less than multiplications…
However the curious reader might wonder if this could be exploited with fewer than gates. The answer is yes, partially. If we view as a vector in a -dimensional -vector space, the attack works whenever:
Where is the linear map:
The acceptance probability for this error vector is:
For the attack we only need to exhibit an error vector with a large kernel. Let:
This is the map . Since on , we have:
Where we used the Freshman’s dream for commuting maps in characteristic . Also:
So . Now consider the kernels obtained by successively applying :
Each step can add at most one dimension. Indeed, modulo , the only new information in is , and this lies in the one-dimensional space . But the chain starts at dimension and ends at dimension , so every step adds exactly one dimension to the kernel:
Now choose the gate errors so that the broken check is exactly . Expanding gives:
Set the error at gate to this coefficient . Then:
Hence , and the verifier accepts this error vector with probability . For , the Freshman’s dream gives , so we only need errors at gates and . This is a circuit with multiplication gates. The intended check would have accepted with probability around , the broken check accepts with probability .
