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 CC over any field Fp\FFp, usually, applied to small fields, as in Inferno, Fp=F2\FFp = \FFtwo. 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 CC evaluated on ww via additive secret sharings between nn 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 xx_\ell and yy_\ell do not combine locally into a sharing of xyx_\ell \cdot y_\ell. So for every multiplication gate [m]\ell \in [m] the prover additionally commits to a sharing of the claimed output zz_\ell, and the verifier has to check that the prover did not lie:

[m]. z=xy \forall \ell \in [m].\ z_\ell = x_\ell \cdot y_\ell

Where x,yx_\ell, y_\ell are the input wires to the \ell-th multiplication gate (themselves linear combinations of earlier wires, hence already shared) and zz_\ell is the freshly committed claim. Every multiplication gate contributes a gate error:

e:=xyzFp e_\ell \coloneqq x_\ell y_\ell - z_\ell \in \FFp

The prover is honest iff every e=0e_\ell = 0. Checking each gate individually is expensive, so Limbo collapses all mm gate errors into a single check. The errors live in Fp\FFp, but the verifier picks a challenge rFqr \in \FFq from a large extension field FqFp\FFq \supseteq \FFp, in Inferno we have Fq=F264\FFq = \FFtsf, and asks the prover to convince him that:

f(r):==1mer=0 f(r) \coloneqq \sum_{\ell = 1}^{m} e_\ell \cdot r^\ell = 0

The errors ee_\ell become the coefficients of a polynomial f(X)Fp[X]f(X) \in \FFp[X], and rr is a uniformly random evaluation point in Fq\FFq. If the prover is honest, ff is the zero polynomial and f(r)=0f(r) = 0 trivially. If even one ee_\ell is non-zero, ff is a non-zero polynomial of degree at most mm, so by Schwartz-Zippel it vanishes at rr with probability at most m/Fqm / |\FFq|. This is Π-MultCheck from Limbo, §4.1, and the rest of the protocol is built on top of it.

Note: the field F264\FFtsf 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 f(r)f(r):

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 r,r2,r3,,rmr, r^2, r^3, \ldots, r^m, the intention is to compute:

sum=irizixsi=rixiysi=yi \begin{align} \mathsf{sum} &= \sum_i r^i \cdot z_i \\ \mathsf{xs}_i &= r_i \cdot x_i \\ \mathsf{ys}_i &= y_i \end{align}

The triple (xs,ys,sum)(\mathsf{xs}, \mathsf{ys}, \mathsf{sum}) is the inner-product instance that the rest of the protocol verifies: downstream the verifier checks that xs,ys=sum\langle \mathsf{xs}, \mathsf{ys} \rangle = \mathsf{sum}. With the intended values, completeness is automatic when the prover is honest (zi=xiyiz_i = x_i y_i). Soundness reduces to evaluating ff at rr:

xs,yssum=rx,ysum=irixiyiirizi=iri(xiyizi)=iriei=f(r) \begin{align} \langle \mathsf{xs}, \mathsf{ys} \rangle - \mathsf{sum} &= \langle \vec{r} \circ \vec{x}, \vec{y} \rangle - \mathsf{sum} \\ &= \sum_i r^i \cdot x_i y_i - \sum_i r^i \cdot z_i \\ &= \sum_i r^i \cdot (x_i y_i - z_i) \\ &= \sum_i r^i \cdot e_i = f(r) \end{align}

So checking xs,ys=sum\langle \mathsf{xs}, \mathsf{ys} \rangle = \mathsf{sum} is exactly checking f(r)=0f(r) = 0.

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: r,r2,r4,r8,,r2m1r, r^2, r^4, r^8, \ldots, r^{2^{m-1}} and the check the verifier ends up performing is therefore:

f(r):=i=0m1eir2i=0 f(r) \coloneqq \sum_{i=0}^{m-1} e_i \cdot r^{2^i} = 0

Where ei=xiyizie_i = x_i y_i - z_i is the error at multiplication gate ii.

Observe that f(r)f(r) does not have degree m1m-1, but instead has degree 2m12^{m-1}; which is huge! So the usual soundness argument, based on the Schwartz–Zippel lemma:

f(X)0    Pr[f(r)=0]deg(f)Fq f(X) \neq 0 \implies \prob_{r}\left[ f(r) = 0 \right] \leq \frac{\deg(f)}{|\FFq|}

Yields nothing when m64m \geq 64, in which case the right side is greater than 11 for Inferno because Fq=F264\FFq = \FFtsf. 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 F2k\FFtwok is the map:

ϕ:F2kF2k,ϕ(x)=x2 \Frob: \FFtwok \to \FFtwok,\quad \Frob(x) = x^2

Its ii-th power is repeated squaring, which is the same as raising to the 2i2^i-th power:

ϕi(x)=ϕ(ϕ((x)))i times=x2i \Frob^i(x) = \underbrace{\Frob(\Frob(\ldots(x)\ldots))}_{i\text{ times}} = x^{2^i}

Linearity.
Observe that ϕ\Frob is F2\FFtwo-linear, because of the “Freshman’s dream”:

ϕ(a+b)=(a+b)2=a2+2ab+b2=a2+b2=ϕ(a)+ϕ(b) \begin{align} \Frob(a + b) &= (a + b)^2 = a^2 + 2ab + b^2 \\ &= a^2 + b^2 = \Frob(a) + \Frob(b) \end{align}

Because 2=02 = 0 in characteristic 2, and multiplication by scalars in F2\FFtwo also works:

ϕ(0a)=0=0ϕ(a),ϕ(1a)=ϕ(a)=1ϕ(a) \Frob(0 \cdot a) = 0 = 0 \cdot \Frob(a),\quad \Frob(1 \cdot a) = \Frob(a) = 1 \cdot \Frob(a)

Composition of F2\FFtwo linear maps is also F2\FFtwo-linear, so every power ϕi\Frob^i is also linear. Furthermore, any linear combination of linear maps is of course also linear, so:

Xieiϕi(X)=iϕi(eiX)=eiX2i X \mapsto \sum_i e_i \cdot \Frob^i(X) = \sum_i \Frob^i(e_i \cdot X) = \sum e_i \cdot X^{2^i}

Where eF2e \in \FFtwo is also a linear map from Fq\FFq to Fq\FFq.

Periodicity.
Finally, on F2k\FFtwok we have ϕk=id\Frob^k = \Id, because for every element of xF2kx \in \FFtwok:

x2k1=1    x2k=x    ϕk(x)=x x^{2^k-1} = 1 \implies x^{2^k} = x \implies \Frob^k(x) = x

Hence:

ϕi=ϕimodk \Frob^i = \Frob^{i \bmod k}

Only kk distinct powers exist as functions F2kF2k\FFtwok \to \FFtwok: ϕ0,ϕ1,,ϕk1\Frob^0, \Frob^1, \ldots, \Frob^{k-1}.

The Exploit

All of this spells doom…

The obvious issue is that f(r)f(r) is a combination of these powers-of-Frobenius, so if e.g.

f(r)=i=064eir2i f(r) = \sum_{i=0}^{64} e_i \cdot r^{2^i}

Then e=(0,0,,0)\vec{e} = (0, 0, \ldots, 0) and e=(1,0,0,,1)\vec{e} = (1, 0, 0, \ldots, -1) have the same evaluation for every rr since: x=ϕ64(x)x = \Frob^{64}(x) so rϕ64(r)=0r - \Frob^{64}(r) = 0 for every rr. Observe that 1=1-1 = 1 in F2\FFtwo and the simplest attack is therefore to simply “flip” the results of the two multiplications: pick any two multiplication gates with indices aba \neq b but such that ab(mod64)a \equiv b \pmod{64}, and set both errors to 11:

f(r)=ϕa(r)+ϕb(r)=ϕamod64(r)+ϕbmod64(r)=0 \begin{align} f(r) &= \Frob^a(r) + \Frob^b(r) \\ &= \Frob^{a \bmod 64}(r) + \Frob^{b \bmod 64}(r) = 0 \end{align}

All this requires is a circuit with more than 6464 multiplication gates, which is pretty certain to be the case, not many useful circuits with less than 6565 multiplications…

However the curious reader might wonder if this could be exploited with fewer than 6565 gates. The answer is yes, partially. If we view rF264r \in \FFtsf as a vector in a 6464-dimensional F2\FFtwo-vector space, the attack works whenever:

rker(fe) r \in \ker(f_{e})

Where fef_e is the linear map:

rieiϕi(r) r \mapsto \sum_i e_i \cdot \Frob^i(r)

The acceptance probability for this error vector is:

ker(fe)F264=2dimF2(ker(fe))64 \frac{|\ker(f_e)|}{|\FFtsf|} = 2^{\dim_{\FFtwo}(\ker(f_e)) - 64}

For the attack we only need to exhibit an error vector with a large kernel. Let:

N=ϕ+id N = \Frob + \Id

This is the map N(r)=r2+rN(r) = r^2 + r. Since ϕ64=id\Frob^{64} = \Id on F264\FFtsf, we have:

N64=(ϕ+id)64=ϕ64+id=0 N^{64} = (\Frob + \Id)^{64} = \Frob^{64} + \Id = 0

Where we used the Freshman’s dream for commuting maps in characteristic 22. Also:

ker(N)={rF264:r2+r=0}=F2 \ker(N) = \{ r \in \FFtsf : r^2 + r = 0 \} = \FFtwo

So dimF2ker(N)=1\dim_{\FFtwo}\ker(N) = 1. Now consider the kernels obtained by successively applying NN:

{0}=ker(N0)ker(N)ker(N2)ker(N64)=F264 \begin{align} \{0\} = \ker(N^0) &\subseteq \ker(N) \subseteq \ker(N^2) \\ &\subseteq \cdots \subseteq \ker(N^{64}) = \FFtsf \end{align}

Each step can add at most one dimension. Indeed, modulo ker(Nj)\ker(N^j), the only new information in rker(Nj+1)r \in \ker(N^{j + 1}) is NjrN^j r, and this lies in the one-dimensional space ker(N)\ker(N). But the chain starts at dimension 00 and ends at dimension 6464, so every step adds exactly one dimension to the kernel:

dimF2ker(Nt)=dimF2ker(Nt1)+1=t \dim_{\FFtwo}\ker(N^t) = \dim_{\FFtwo}\ker(N^{t - 1}) + 1 = t

Now choose the gate errors so that the broken check is exactly NtN^t. Expanding NtN^t gives:

Nt=(ϕ+id)t=i=0t(ti)ϕi=i=0teiϕi,ei=(ti)mod2 \begin{align} N^t &= (\Frob + \Id)^t = \sum_{i=0}^t \binom{t}{i}\Frob^i \\ &= \sum_{i=0}^t e_i \Frob^i,\quad e_i = \binom{t}{i} \bmod 2 \end{align}

Set the error at gate ii to this coefficient eie_i. Then:

fe(r)=i=0teiϕi(r)=Nt(r) f_e(r) = \sum_{i=0}^t e_i \Frob^i(r) = N^t(r)

Hence dimF2ker(fe)=t\dim_{\FFtwo}\ker(f_e) = t, and the verifier accepts this error vector with probability 2t642^{t - 64}. For t=32t = 32, the Freshman’s dream gives N32=ϕ32+idN^{32} = \Frob^{32} + \Id, so we only need errors at gates 00 and 3232. This is a circuit with 3333 multiplication gates. The intended check would have accepted with probability around 33/26433/2^{64}, the broken check accepts with probability 2322^{-32}.