3052 words
15 minutes
SekaiCTF 2025 Writeup #2 - unfairy-ring
2025-08-17

Writeup for unfairy-ring from SekaiCTF 2025.

TLDR: Following the approach proposed by Miura1 and Cheng2, with some careful variable management, we can transform the equations into

{x12+L1(x2,,xn)x1+Q1(x2,,xn)=t1x22+L2(x3,,xn)x2+Q2(x3,,xn)=t2xm2+Lm(xm+1,,xn)xm+Qm(xm+1,,xn)=tm\left\{ \begin{align*} x_1^2 + L_1(x_2, \cdots, x_n)x_1 + Q_1(x_2, \cdots, x_n) &= t_1 \\ x_2^2 + L_2(x_3, \cdots, x_n)x_2 + Q_2(x_3, \cdots, x_n) &= t_2 \\ &\vdots \\ x_m^2 + L_m(x_{m+1}, \cdots, x_n)x_m + Q_m(x_{m+1}, \cdots, x_n) &= t_m \\ \end{align*} \right.

Then randomly set xm+1,,xnx_{m+1}, \cdots, x_n and solve the equations from xmx_m to x1x_1.

Introduction#

from functools import reduce
from uov import uov_1p_pkc as uov # https://github.com/mjosaarinen/uov-py/blob/main/uov.py
import os
FLAG = os.getenv("FLAG", "SEKAI{TESTFLAG}")
def xor(a, b):
assert len(a) == len(b), "XOR inputs must be of the same length"
return bytes(x ^ y for x, y in zip(a, b))
names = ['Miku', 'Ichika', 'Minori', 'Kohane', 'Tsukasa', 'Kanade']
pks = [uov.expand_pk(uov.shake256(name.encode(), 43576)) for name in names]
msg = b'SEKAI'
sig = bytes.fromhex(input('Ring signature (hex): '))
assert len(sig) == 112 * len(names), 'Incorrect signature length'
t = reduce(xor, [uov.pubmap(sig[i*112:(i+1)*112], pks[i]) for i in range(len(names))])
assert t == uov.shake256(msg, 44), 'Invalid signature'
print(FLAG)

This challenge is adapted from defund’s fairy-ring, which is a 6 key ring signature scheme based on UOV. The original solution exploits the key reuse vulnerability and solves the linear equation f(x)f(x+δ)=yf(x)-f(x+\delta)=y for an arbitrary quadratic function ff.

During DiceCTF, I was working on nil-circ and didn’t have chance to check the fairy-ring code. There was also some discussion about solving it without key reuse after the CTF ended, perhaps Neobeo can comment on it.

So it is quite impressive when Neobeo said that he can solve 7 key case without key reuse and will make a revenge in SekaiCTF. The only hints I got from Neobeo are these two screenshots:

hint1 hint2

The actual solution is far more complicated than the hints and includes many tricks. So in the writeup I’ll try to start from the very beginning and explain how we finally get to the 6 keys solution step by step.

Preliminaries#

Some mathematical tools we’ll use later.

Quadratic Forms over Characteristic 2 Field#

Def. A quadratic form is given by fA(x)=i,jaijxixj=xAxf_{A}(\mathbf{x}) = \sum_{i, j}a_{ij}x_ix_j = \mathbf{x}^\top A\mathbf{x}, where A=(aij)A=(a_{ij}). In a characteristic 2 field, the matrix MM is not required to be symmetric.

Here, we only consider the homogeneous quadratic forms because the signature scheme doesn’t involve any linear terms.

Def. The polar form of a quadratic form is fA(x,y)=fA(x+y)fA(x)fA(y)f'_{A}(\mathbf{x}, \mathbf{y}) = f_{A}(\mathbf{x}+\mathbf{y}) - f_{A}(\mathbf{x}) - f_{A}(\mathbf{y}).

Lemma. The polar form is bilinear.

Expanding the polar form, we get

fA(x,y)=fA(x+y)fA(x)fA(y)=(x+y)A(x+y)xAxyAy=xAy+yAx=x(A+A)y\begin{align*} f'_{A}(\mathbf{x}, \mathbf{y}) &= f_{A}(\mathbf{x}+\mathbf{y}) - f_{A}(\mathbf{x}) - f_{A}(\mathbf{y}) \\ &= (\mathbf{x}+\mathbf{y})^\top A (\mathbf{x}+\mathbf{y}) - \mathbf{x}^\top A \mathbf{x} - \mathbf{y}^\top A \mathbf{y} \\ &= \mathbf{x}^\top A \mathbf{y} + \mathbf{y}^\top A \mathbf{x} \\ &= \mathbf{x}^\top (A + A^\top) \mathbf{y} \end{align*}

You can see that the polar form is bilinear. The matrix A+AA + A^\top is symmetric and its diagonal is 00 when over characteristic 2 field.

Prop. If the dimension of AA is odd, A+AA + A^\top will be singular over characteristic 2 field.

Proof omitted.

Def. If we can split a quadratic form into two disjoint parts, i.e. fA(x)=fB(xB)+fC(xC)f_A(\mathbf{x})=f_B(\mathbf{x}_B)+f_C(\mathbf{x}_C), these two parts are orthogonal.

Def. A linear transformation of a quadratic form is given by x=Ty\mathbf{x} = T\mathbf{y}, where TT is an arbitrary matrix. After the transformation, the quadratic form becomes fA(x=Ty)=(Ty)A(Ty)=yTATyf_A(\mathbf{x} = T\mathbf{y})=(T\mathbf{y})^\top A(T\mathbf{y})=\mathbf{y}^\top T^\top AT\mathbf{y}. The new matrix A=TATA'=T^\top AT is congruent to the original matrix AA.

We don’t ask TT to be invertible or square, so the transformation is only injective from y\mathbf{y} to x\mathbf{x}. As a result, the new quadratic form fAf_{A'} is a “subset” of the original quadratic form fAf_A.

The last property of the characteristic 2 field is Frobenius endomorphism F(x)=x2rF(x)=x^{2^r}. It is a linear map over characteristic 2 field and will be used in the Thomae Wolf’s approach later.

Ring UOV Signature Scheme#

Def. Function FFnFm\mathcal{F}\in\mathbb{F}^n\to\mathbb{F}^m is a multivariate quadratic map if every component of F\mathcal{F} is a quadratic form, i.e., F(x)=(fA1(x),,fAm(x))\mathcal{F}(\mathbf{x})=(f_{A_1}(\mathbf{x}), \cdots, f_{A_m}(\mathbf{x})).

A detailed explanation of the UOV signature scheme can be found in defund’s post. Basically, UOV is a multivariate quadratic map with trapdoor that can solve equation F(x)=t\mathcal{F}(\mathbf{x})=\mathbf{t} efficiently.

We can rewrite UOV into equations of the form

fAi(x)=xAix=ti,1im\renewcommand{\vec}[1]{\mathbf{#1}} f_{A_i}(\vec{x})=\vec{x}^\top A_i\vec{x}=t_i, 1\leq i \leq m

Def. MQm(n)MQ^{m}(n) denotes a multivariate quadratic problem with mm equations and nn variables.

Then we can extend the definition to a ring signature scheme.

Def. MQm(n1,,nk)MQ^{m}(n_1, \cdots, n_k) denotes a ring signature scheme involving kk quadratic maps FiFniFm\mathcal{F}_i\in\mathbb{F}^{n_i}\to\mathbb{F}^m, s.t. i=1kFi(xi)=t\sum_{i=1}^k \mathcal{F}_i(\mathbf{x}_i) = \mathbf{t}, where xiFni\mathbf{x}_i\in\mathbb{F}^{n_i}.

This challenge can be formulated as an instance of MQm(n,,n)MQ^{m}(n, \cdots, n) with k=6,n=112k=6, n=112 and m=44m=44, and no trapdoor is present.

Thomae Wolf’s Method#

In the first hint, Neobeo gives two papers Furue et al.3 and Hashimoto4 and both papers explains Thomae Wolf’s idea. I recommend reading this slide from PQCrypto 2021.

It’s not relevant to the actual solution, but I’ve finished this section before the drama happened, so I’ll keep it here.

Theorem. (Thomae Wolf) For an underdetermined MQm(n)MQ^m(n) problem, there’s a polynomial time algorithm to reduce it to MQnα(nα)MQ^{n-\alpha}(n-\alpha), where α=nm1\alpha=\lfloor\frac{n}{m}\rfloor-1.

The algorithm consists of three steps:

  1. Find a linear transformation TT such that in fMi,1iαf_{M_i'}, 1\leq i\leq \alpha, the variables x1,x2,,xmx_1, x_2, \cdots, x_m only appear in squared terms.
  2. Choose xm+1,,xnx_{m+1},\cdots, x_n properly so that the equations become linear in x12,x22,,xm2x_1^2, x_2^2, \cdots, x_m^2.
  3. Apply Frobenius endomorphism to transform these equations into linear equations in x1,x2,,xmx_1, x_2, \cdots, x_m.

Here I’ll formulate the steps in polar form.

Step 1#

Firstly we’ll find a set of linear independent vectors v1,v2,,vm\mathbf{v}_1,\mathbf{v}_2,\cdots,\mathbf{v}_m such that

fMl(vi,vj)=0,1i<jm,1lαf_{M_l}'(\mathbf{v}_i, \mathbf{v}_j)=0, \forall 1\leq i<j\leq m, 1\leq l\leq\alpha

We can start with v1=e1\mathbf{v}_1=\mathbf{e}_1. For each 2jm2\leq j\leq m, solve fMl(vi,vj)=0,1iα,l<jf_{M_l}'(v_i, v_j)=0, 1\leq i\leq\alpha, l<j and get vj\mathbf{v}_j.

T=(v1,v2,,vm)T=(\mathbf{v}_1, \mathbf{v}_2, \cdots, \mathbf{v}_m)^\top

Extend TT to a complete basis and apply the transformation to the equations. As a result, the first mm variables in the first α\alpha equations will be linear in squared terms. i.e. it could be written as

fMl=i=1maii(l)xi2+i=1mLi(l)(xm+1,,xn)xi+Q(l)(xm+1,,xn)=tl,1lαf_{M_l}=\sum_{i=1}^ma^{(l)}_{ii}x_i^2+\sum_{i=1}^mL_i^{(l)}(x_{m+1},\cdots, x_n)x_i+Q^{(l)}(x_{m+1},\cdots, x_n)=t_l, 1\leq l\leq \alpha

where Li(l)L_i^{(l)} is linear and Q(l)Q^{(l)} is quadratic.

To ensure that vm\mathbf{v}_m satisfies the polar form conditions and is linearly independent of the first m1m-1 vectors, α\alpha can’t be too large. Therefore, the condition nα(m1)+mn\geq\alpha (m-1)+m is required.

Step 2#

In the second step, we want to eliminate the linear terms Li(l)(xm+1,,xn)L_i^{(l)}(x_{m+1},\cdots, x_n). In our definition, quadratic forms don’t contain linear terms, hence we can directly set xj=0x_j=0 for j>mj>m . However, in the Thomae-Wolf algorithm, the linear terms are non-zero and it’s necessary to solve the equations Li(l)(xm+1,,xn)=0L_i^{(l)}(x_{m+1},\cdots, x_n)=0 and eliminate them.

To ensure the existence of a solution, nmαmn-m\geq\alpha m must hold. Finally, we get

i=1maii(l)xi=tl,1lα\sum_{i=1}^ma^{(l)}_{ii}x_i=t_l', 1\leq l\leq \alpha

Step 3#

In F2r\mathbb{F}_{2^r}, we can raise it to 2r12^{r-1} power and obtain

i=1m(aii(l))2r1xi=(tl)2r1\sum_{i=1}^m\left(a^{(l)}_{ii}\right)^{2^{r-1}}x_i=\left({t_l'}\right)^{2^{r-1}}

After elimination, we get a MQnα(nα)MQ^{n-\alpha}(n-\alpha) problem. We need nmαmn-m\geq\alpha m to ensure the step 1 and 2 has a solution, thus αnm1\alpha\leq\lfloor\frac{n}{m}\rfloor-1.

Complexity of Guessing Analysis#

In Furue’s work, they improved Thomae Wolf’s algorithm by adding a guessing step. It is very helpful for improving the complexity of the algorithm.

For example, we can ignore one equation and solve the remaining m1m-1 equations. This would cause a guessing complexity of 1256\frac{1}{256} for each guessed equation. We also need the probability of sampling a low rank matrix, which is estimated by the following lemma.

Prop. For a uniformly distributed matrix MF256m×n,nm,Pr[rank(M)mk]1256k(nm+k).M\in\mathbb{F}_{256}^{m\times n}, n \geq m, \Pr[\text{rank}(M) \leq m-k] \approx \frac{1}{256^{k(n-m+k)}}.

Although the proof is not rigorous, we can first fix the first mkm-k rows of MM and it is likely to be linear independent, then estimate the probability of the remaining kk rows being linear dependent on the first mkm-k rows. for each row, only mkm-k entries are free, and the remaining nm+kn-m+k entries are determined by the first mkm-k rows. Therefore, the probability of each row being linear dependent on the first mkm-k rows is 1256nm+k\frac{1}{256^{n-m+k}}. So ignoring the probability of the first mkm-k rows being linear dependent, the probability of sampling a rank mkm-k matrix is 1256k(nm+k)\frac{1}{256^{k(n-m+k)}}.

In most cases of a quadratic form, nn is much larger than mm, thus it’s very unlikely to sample a low rank matrix.

Solution#

The solution is similar to the algorithm in Cheng’s paper2. However, I wasn’t aware of it until two months later when I was writing this writeup. Luckily, it didn’t become a full implement-paper challenge because the orthogonal structure of ring signature is needed to solve the 6 keys case.

Iterative reduction#

We’ve got two basic operations to reduce the equations,

  1. Replace fAi=tif_{A_i} = t_i with fAicfAj=tictjf_{A_i}-cf_{A_j} = t_i - ct_j, where cc is a constant.
  2. Apply a linear transformation x=Ty\mathbf{x} = T\mathbf{y}. TT may be non-square and y\mathbf{y} is a subspace of x\mathbf{x}.

With these two operations, we can reduce the number of equations iteratively.

Lemma. MQm(n1,,nk)\text{MQ}^{m}(n_1, \cdots, n_k) can be reduced to MQm1(n1m1,,nk)\text{MQ}^{m-1}(n_1-m-1, \cdots, n_k).

The plan is to use linear transformation to make one variable orthogonal. In general, it’s impossible to make all equations orthogonal, so we must cost some variables to achieve that.

For a linear transformation TT, let’s say the columns of TT are t0,,tk\mathbf{t}_0, \cdots, \mathbf{t}_k. We can express the orthogonal condition by the polar form fAi(t0,tj)=0,1jkf_{A_i}'(\mathbf{t}_0,\mathbf{t}_j)=0, 1\leq j\leq k. Hence if we randomly set t0\mathbf{t}_0, the rest tj\mathbf{t}_j are all in the kernel of linear equations fAi(t0,tj)=0f_{A_i}'(\mathbf{t}_0,\mathbf{t}_j)=0. Since we’ve got mm equations, the dimension of the kernel is at least nmn-m. Note that fAi(t0,t0)=0f_{A_i}'(\mathbf{t}_0,\mathbf{t}_0)=0 always holds, so the largest subspace we can get is k=nm1k=n-m-1.

Once we have an orthogonal variable, we can do the first operation to eliminate the square terms x02x_0^2. Finally, we will get

{x02+Q1(x1,)=t1Q2(x1,)=t2Qm(x1,)=tm\left\{ \begin{align*} x_0^2 + Q_1(x_1, \cdots) &= t_1 \\ Q_2(x_1, \cdots) &= t_2 \\ &\vdots \\ Q_m(x_1, \cdots) &= t_m \\ \end{align*} \right.

Now we can keep the last m1m-1 equations and keep reducing it. After finding a solution of x1,x_1, \cdots, x0x_0 can be easily solved by x02=t1Q1(x1,)x_0^2 = t_1 - Q_1(x_1, \cdots) and the Frobenius endomorphism ensures that x1x_1 always has a solution. The operations we’ve done don’t damage the orthogonal structure and only the first n1n_1 variables are affected during the reduction. Hence, the last m1m-1 equations is a MQm1(n1m1,n2,,nk)MQ^{m-1}(n_1-m-1, n_2, \cdots, n_k) problem.

Improving the reduction#

In the previous reduction, the main cost comes from the mm equations fAi(t0,x)=0f_{A_i}'(\mathbf{t}_0,\mathbf{x})=0. But these equations aren’t promised to be linear independent. Actually, the rank of the equations may be less than mm.

There’s two possible ways to lower the rank of the equations.

  1. Choose good t0\mathbf{t}_0.
  2. Randomly do many reductions and hope that the rank is less than mm.

Choosing good t0\mathbf{t}_0#

The linear equations can be written as a matrix whose rows are t0(Ai+Ai)\mathbf{t}_0^\top(A_i + A_i^\top). If we can find a linear combination of the rows to be zero, the rank of the matrix will be less than mm. For odd nn, the matrix (Ai+Ai)(A_i + A_i^\top) is always singular, so simply letting t0\mathbf{t}_0 be the kernel will work. For even nn, we have to do some combinations to find a singular matrix. Consider (Ai+Ai)+t(Aj+Aj),tF256(A_i + A_i^\top)+t(A_j + A_j^\top), t\in \mathbb{F}_{256}, Its determinant is a polynomial of tt. If we can find a root of the polynomial, the matrix will be singular.

With the rank minimizing trick, each reduction only costs mm variables now. But it’s still unlikely to find t0\mathbf{t}_0 that’s in the kernel of two matrices.

Guessing low rank matrix#

What’s the probability of sampling a low rank matrix?

With a good t0\mathbf{t}_0, it is m1m-1 independent equations. After exculding the trivial solution t0\mathbf{t}_0, there’s n1n-1 variables in the equations. Hence the shape of the matrix is (m1,n1)(m-1, n-1) and we’ve shown that the probability of the rank being less than mm is 1256nm+1\frac{1}{256^{n-m+1}}. So only if nmn-m is small, it is possible to sample a low rank matrix.

Lemma. MQm(m,n2,,nk)\text{MQ}^{m}(m, n_2, \cdots, n_k) can be reduced to MQm1(1,n2,,nk)\text{MQ}^{m-1}(1, n_2, \cdots, n_k).

This is the only case that guessing may get low rank matrix with prob 1256\frac{1}{256}.

Guessing equations#

Before reduction, we can remove some equations and guess them later. Every equation has 1256\frac{1}{256} probability to match the target, so we can at most remove 44 equations at first, which gives 2322^{32} complexity.

7 keys#

The whole reduction procedure can be described as follows:

n = 112
m = 42 # plus two guessed equations
k = 7
t = [n]*k
seq = [6,6,6,5,2,5,5,3,2,1,4,3,0,4,4,4,3,3,2,2,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0]
for ind in seq:
print(t, m, ind)
if t[ind] < m:
if t[ind] == 0:
raise ValueError
t[ind] = 0
m -= 1
continue
elif t[ind] <= m+1:
t[ind] = 0
m -= 2
continue
else:
t[ind] -= m
m -= 1

seq is the sequence of key indices that we will reduce. It’s non-trivial to find the optimal sequence, so I just manually solved it. We need to guess 2 equations, which is 2162^{16} attempts.

Implementation & optimization#

The backward step involves inverse linear transformation and solving x02=t1Q1(x1,)x_0^2 = t_1 - Q_1(x_1, \cdots). It is the most time-consuming part when brute forcing the solutions. Since the inverse transformation is still linear, the only non-linearity is the square root, which happens only mm times. Hence the matrix of Q1(x1,)Q_1(x_1, \cdots) can be compressed into a smaller size.

It is wrapped in a Transform class, which has compress method to compress the matrix when reduction and compress_backward method to do the backward step with compressed matrix.

class Transform:
def __init__(self):
pass
def forward(self, problem: ProblemData):
"""
Reduce the problem to a smaller size.
"""
raise NotImplementedError
def compress(self, Ts):
"""
Compress the matrix for the backward step.
"""
raise NotImplementedError
def backward(self, x):
"""
Basic backward.
"""
raise NotImplementedError
def compress_backward(self, x_compressed):
"""
Do the backward step with compressed matrix.
"""
raise NotImplementedError

After compression, the backward step can reach 3000 iter/s with 16 cores in pure sage implementation.

Final code for 7 keys#

solve7.sage
from tqdm import tqdm
from dataclasses import dataclass
from functools import lru_cache
from concurrent.futures import ProcessPoolExecutor
import random, os
from uov import uov_1p_pkc as uov
F = GF(2**8, name='z', modulus=x^8 + x^4 + x^3 + x + 1)
z = F.gen()
R = PolynomialRing(F, 'xbar')
xbar = R.gen()
elms = [F.from_integer(i) for i in range(2**8)]
n = 112
m = 42
def gen(n, m):
U = random_matrix(F, n, n)
while U.det() == 0:
U = random_matrix(F, n, n)
pk = []
for _ in range(m):
M = random_matrix(F, n, n)
M[:m, :m] = 0
M = U.transpose() * M * U
pk.append(M)
return pk
def from_uov_vector(elms):
m = uov.m
bs = elms.to_bytes(m, 'big')
vs = [F.from_integer(i) for i in bs]
return vs
def from_uov(pk):
v = uov.v
m = uov.m
Ms = [[[0] * n for _ in range(n)] for _ in range(m)]
m1 = uov.unpack_mtri(pk, v)
m2 = uov.unpack_mrect(pk[uov.p1_sz:], v, m)
m3 = uov.unpack_mtri(pk[uov.p1_sz + uov.p2_sz:], m)
for i in range(v):
for j in range(i, v):
vec = from_uov_vector(m1[i][j])
for k in range(m):
Ms[k][i][j] = vec[k]
for i in range(v):
for j in range(m):
vec = from_uov_vector(m2[i][j])
for k in range(m):
Ms[k][i][j + v] = vec[k]
for i in range(m):
for j in range(i, m):
vec = from_uov_vector(m3[i][j])
for k in range(m):
Ms[k][i + v][j + v] = vec[k]
Ms = [matrix(F, M) for M in Ms]
return Ms
def to_uov(sol):
output = []
for s in sol:
output.append([x.to_integer() for x in s.list()])
output = [bytearray(x) for x in output]
return output
def F_sqrt(x):
return x.square_root()
class EQ:
def __init__(self, Ms: list, v):
self.Ms = Ms
self.v = v
def evaluate(self, sigs):
result = 0
for M, sig in zip(self.Ms, sigs):
result += sig * M * sig
result += self.v
return result
def compress(self, Ts):
new_Ms = []
for M, T in zip(self.Ms, Ts):
new_Ms.append(T.transpose() * M * T)
return EQ(new_Ms, self.v)
@property
def ns(self):
return [M.nrows() for M in self.Ms]
def __mul__(self, other):
other = F(other)
new_Ms = [other * M for M in self.Ms]
new_v = other * self.v
return EQ(new_Ms, new_v)
def __add__(self, other):
assert isinstance(other, EQ)
assert len(self.Ms) == len(other.Ms)
new_Ms = [M + other.Ms[i] for i, M in enumerate(self.Ms)]
new_v = self.v + other.v
return EQ(new_Ms, new_v)
def __rmul__(self, other):
other = F(other)
return self.__mul__(other)
class ProblemData:
def __init__(self, eqs: list[EQ]):
self.eqs = eqs
self.ns = eqs[0].ns
self.k = len(self.ns)
self.m = len(eqs)
for i in range(1, self.m):
assert eqs[i].ns == self.ns
def check(self, sigs):
vs = [eq.evaluate(sigs) for eq in self.eqs]
return all(v == 0 for v in vs)
def get_M(self, i, index):
return self.eqs[i].Ms[index]
def get_key(self, index):
Ms = [self.get_M(i, index) for i in range(self.m)]
return Ms
def compress(self, Ts):
new_eqs = [eq.compress(Ts) for eq in self.eqs]
return ProblemData(new_eqs)
class Transform:
def __init__(self):
pass
def forward(self, problem: ProblemData):
raise NotImplementedError
def compress(self, Ts):
raise NotImplementedError
def backward(self, x):
raise NotImplementedError
def compress_backward(self, x_compressed):
raise NotImplementedError
class Transform0(Transform):
def __init__(self, index):
self.index = index
self.eq = None
def forward(self, problem: ProblemData):
Ms = problem.get_key(self.index)
Ms = [M + M.transpose() for M in Ms]
assert all(M[0].is_zero() for M in Ms)
m = len(Ms)
eqs = problem.eqs[:]
for i0 in range(m):
if Ms[i0][0, 0] != 0:
break
eqs[0], eqs[i0] = eqs[i0], eqs[0]
coeffs = [eqs[i].Ms[self.index][0, 0] for i in range(problem.m)]
eqs[0] = eqs[0] * coeffs[0].inverse()
for i in range(1, problem.m):
eqs[i] = eqs[i] + (-coeffs[i]) * eqs[0]
assert eqs[0].Ms[self.index][0, 0] == 1
for i in range(problem.m):
eqs[i].Ms[self.index] = eqs[i].Ms[self.index][1:, 1:]
self.eq = eqs.pop(0)
return ProblemData(eqs)
def compress(self, Ts):
self.compressed_eq = self.eq.compress(Ts)
new_Ts = Ts[:]
new_Ts[self.index] = block_matrix(F, [[identity_matrix(1), 0], [0, Ts[self.index]]])
return new_Ts
def backward(self, x):
u = self.eq.evaluate(x)
x0 = F_sqrt(u)
v0 = x[self.index]
v1 = vector(F, [x0] + v0.list())
x_new = x[:]
x_new[self.index] = v1
return x_new
def compress_backward(self, x_compressed):
u = self.compressed_eq.evaluate(x_compressed)
x0 = F_sqrt(u)
v0 = x_compressed[self.index]
v1 = vector(F, [x0] + v0.list())
x_new = x_compressed[:]
x_new[self.index] = v1
return x_new
class Transform1(Transform):
def __init__(self, index, T):
self.index = index
self.T = T
def forward(self, problem):
return ProblemData([self.apply_T(eq) for eq in problem.eqs])
def apply_T(self, eq: EQ):
Ms = eq.Ms[:]
Ms[self.index] = self.T.transpose() * Ms[self.index] * self.T
return EQ(Ms, eq.v)
def compress(self, Ts):
new_Ts = Ts[:]
new_Ts[self.index] = self.T * Ts[self.index]
return new_Ts
def backward(self, x):
x0 = x[self.index]
x_new = x[:]
x_new[self.index] = self.T * x0
return x_new
def compress_backward(self, x_compressed):
return x_compressed
class Transform2(Transform):
def __init__(self, index):
self.index = index
self.eq = None
self.pad = 0
def forward(self, problem: ProblemData):
Ms = problem.get_key(self.index)
m = len(Ms)
self.pad = Ms[0].nrows() - 1
eqs = problem.eqs[:]
for i0 in range(m):
if Ms[i0][0, 0] != 0:
break
eqs[0], eqs[i0] = eqs[i0], eqs[0]
coeffs = [eqs[i].Ms[self.index][0, 0] for i in range(problem.m)]
eqs[0] = eqs[0] * coeffs[0].inverse()
for i in range(1, problem.m):
eqs[i] = eqs[i] + (-coeffs[i]) * eqs[0]
assert eqs[0].Ms[self.index][0, 0] == 1
new_eqs = []
for i in range(problem.m):
eq = eqs[i]
Ms = eq.Ms[:]
Ms.pop(self.index)
new_eqs.append(EQ(Ms, eq.v))
self.eq = new_eqs.pop(0)
return ProblemData(new_eqs)
def compress(self, Ts):
self.compressed_eq = self.eq.compress(Ts)
new_T = matrix(F, [[1]] + [[0] for _ in range(self.pad)])
new_Ts = Ts[:self.index] + [new_T] + Ts[self.index:]
return new_Ts
def backward(self, x):
u = self.eq.evaluate(x)
x0 = F_sqrt(u)
v1 = vector(F, [x0] + [0] * self.pad)
x_new = x[:self.index] + [v1] + x[self.index:]
return x_new
def compress_backward(self, x_compressed):
u = self.compressed_eq.evaluate(x_compressed)
x0 = F_sqrt(u)
v1 = vector(F, [x0])
x_new = x_compressed[:self.index] + [v1] + x_compressed[self.index:]
return x_new
def solve_T_even(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n+1:
raise ValueError
for e in elms:
M0 = Ms2[0] + e * Ms2[1]
if M0.det() != 0:
break
M1 = Ms2[1] * M0.inverse()
M2 = Ms2[2] * M0.inverse()
M3 = Ms2[3] * M0.inverse()
for e1 in elms:
for e2 in elms:
MM = M1 + e1 * M2 + e2 * M3
r = MM.charpoly().roots()
if len(r) == 0:
continue
r0 = r[0][0]
b = (MM-r0*identity_matrix(F, n)).left_kernel().basis()[0]
if b[0] == 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
Ms3 = [T*M*T.transpose() for M in Ms]
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms3[i] + Ms3[i].transpose()
C[i] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def solve_T_small(Ms):
n = Ms[0].nrows()
k = len(Ms)
if k >= n+1:
raise ValueError
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms[i] + Ms[i].transpose()
C[i] = M_tmp[0][1:]
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return T2.transpose()
def solve_T_odd(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n+1:
raise ValueError
M0 = Ms2[0]
M1 = Ms2[1]
M2 = Ms2[2]
for e1 in elms:
for e2 in elms:
MM = M0 + e1 * M1 + e2 * M2
b = MM.left_kernel().basis()[0]
if b[0] == 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
Ms3 = [T*M*T.transpose() for M in Ms]
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms3[i] + Ms3[i].transpose()
C[i] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def make_one_linear(problem: ProblemData, index):
ns = problem.ns
Ms = problem.get_key(index)
if problem.m <= 2:
T = solve_T_small(Ms)
elif ns[index] % 2 == 0:
T = solve_T_even(Ms)
else:
T = solve_T_odd(Ms)
if T is None:
raise ValueError
transform = Transform1(index, T)
problem = transform.forward(problem)
return problem, transform
def eliminate(problem: ProblemData, index):
transform = Transform0(index)
problem = transform.forward(problem)
return problem, transform
def remove_one_key(problem: ProblemData, index):
transform = Transform2(index)
problem = transform.forward(problem)
return problem, transform
names = ['Miku', 'Ichika', 'Minori', 'Kohane', 'Tsukasa', 'Kanade', 'Mai']
pks_uov = [uov.expand_pk(uov.shake256(name.encode(), 43576)) for name in names]
pks = [from_uov(pk) for pk in pks_uov]
t = uov.shake256(b'shrooms', 44)
target = [F.from_integer(i) for i in t]
# pks = [gen(n, 44) for _ in range(7)]
# target = random_vector(F, m)
eqs = [EQ(list(Ms), t) for Ms, t in zip(list(zip(*pks)), target)]
problem_orig = ProblemData(eqs)
# random shuffle
for i in range(256):
ind = random.randrange(1, 44)
elm = random.choice(elms)
eqs[ind] = eqs[ind] + elm * eqs[0]
eqs[0], eqs[ind] = eqs[ind], eqs[0]
if i > 128 and eqs[0].v != 0:
break
problem0 = ProblemData(eqs[:m])
rest_eq0, rest_eq1 = eqs[m:]
def walk(problem, index):
print(problem.ns, problem.k, problem.m, index)
n = problem.ns[index]
m = problem.m
if n == 0:
raise ValueError
if n < m:
return [remove_one_key(problem, index)]
elif n > m+1:
prob1, transform1 = make_one_linear(problem, index)
prob2, transform2 = eliminate(prob1, index)
return [(prob1, transform1), (prob2, transform2)]
elif n == m+1 or n == m:
prob1, transform1 = make_one_linear(problem, index)
prob2, transform2 = eliminate(prob1, index)
prob3, transform3 = remove_one_key(prob2, index)
return [(prob1, transform1), (prob2, transform2), (prob3, transform3)]
seq = [6,6,6,5,2,5,5,3,2,1,4,3,0,4,4,4,3,3,2,2,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0]
path = []
problem = problem0
for ind in seq:
path.extend(walk(problem, ind))
problem = path[-1][0]
print(problem.ns, problem.k, problem.m, ind)
eq0 = problem.eqs[0]
v = eq0.v
M = eq0.Ms[0]
Ts = [identity_matrix(F, 5)]
for problem, transform in reversed(path):
Ts = transform.compress(Ts)
problem0_compressed = problem0.compress(Ts)
rest_eq0_compressed = rest_eq0.compress(Ts)
rest_eq1_compressed = rest_eq1.compress(Ts)
def try_solve():
x0 = random_vector(F, 5)
val = (x0 * M * x0)
if val == 0:
return
c = F_sqrt(v / val)
x0 = c * x0
sol = [x0]
for problem, transform in reversed(path):
# assert problem.check(sol), (problem.ns, problem.k, problem.m, len(sol), [len(x) for x in sol], type(transform))
# sol = transform.backward(sol)
sol = transform.compress_backward(sol)
# assert problem0.check(sol)
# assert problem0_compressed.check(sol)
if rest_eq0_compressed.evaluate(sol) == 0:
if rest_eq1_compressed.evaluate(sol) == 0:
print("Found solution")
return sol
pbar = tqdm()
def worker_init():
set_random_seed(os.getpid())
with ProcessPoolExecutor(max_workers=16, initializer=worker_init) as executor:
while True:
tasks = [executor.submit(try_solve) for _ in range(256)]
for task in tasks:
sol = task.result()
if sol is not None:
break
pbar.update(1)
for task in tasks:
task.cancel()
if sol is not None:
break
pbar.close()
real_sol = [T*x for T, x in zip(Ts, sol)]
assert problem0.check(real_sol)
assert rest_eq0.evaluate(real_sol) == 0
assert rest_eq1.evaluate(real_sol) == 0
assert problem_orig.check(real_sol)
sol = to_uov(real_sol)
from pwn import xor
t = xor(*[uov.pubmap(s, pk) for s, pk in zip(sol, pks_uov)])
print(t.hex())
print(uov.shake256(b'shrooms', 44).hex())

More reduction tricks#

The last challenge is to make it to 6 keys. If we just apply the same method, we need to guess 5 equations, which is 2402^{40} complexity. From Neobeo’s experiment which is written in c, it takes  1~1 hour 20 cores to hit 2322^{32} times, so hitting 2402^{40} is not feasible for a two day CTF.

Extending to affine variable#

The idea is that we can reduce ai(x02+x0)+Qi(x1,x2,,xn)a_i(x_0^2+x_0)+Q_i(x_1,x_2,\cdots,x_n). Here the coefficients of x0x_0 are not 00. However, it is still proportional to x02x_0^2, so the elimination still works.

The equations now becomes fAi(t0,t0)=kfAi(t0)f_{A_i}'(\mathbf{t}_0,\mathbf{t}_0)=kf_{A_i}(\mathbf{t}_0), where kk is a constant. After eliminating kk, we get m1m-1 equations. Still we can choose good t0\mathbf{t}_0 to remove one equation, but the requirement is more strict now. For the corresponding (A+A)t0=0(A+A^\top)\mathbf{t}_0=0, fA(t0)=0f_{A}(\mathbf{t}_0)=0 is also required. The square terms are hard to control, so we can only guess it with probability 1256\frac{1}{256}.

Another problem is that only half of the equations x^2+x=? have solutions. It seems like a big problem because each time we have half of the possibility to fail. But actually, the rest of the half have two solutions, so in average we still have one solution in every backward step. This is also the main idea in Cheng’s paper2.

Therefore, we derive the following reduction:

Lemma. MQm(n1,,nk)\text{MQ}^{m}(n_1, \cdots, n_k) can be reduced to MQm1(n1m+1,,nk)\text{MQ}^{m-1}(n_1-m+1, \cdots, n_k).

Homogeneous#

After the reduction, we can solve it with 4 guessed equations. The solution done by Neobeo implemented it and can finish in 1 hour with 20 cores. But it’s still not enough for my pure sage implementation. So I came up with this idea.

You may have noticed that the equations of UOV are homogeneous, so it has a nice property f(ax)=a2f(x)f(ax)=a^2f(x). My idea is to get one free equation from the homogeneous property.

The homogeneous says that we can sign all a2ta^2\mathbf{t} simultaneously. If t=0\mathbf{t}=0, we get 255 different non-zero signs of t=0\mathbf{t}=0, that makes a free equation possible.

By doing fAicfAj=tictjf_{A_i}-cf_{A_j} = t_i - ct_j at the beginning, we can let ti=0t_i=0 except for one equation. Therefore, only 2242^{24} attempts are needed to guess the rest 3 equations.

6 keys#

Similar to 7 keys, the script shows the variable usage in the reduction procedure.

n = 112
m = 40 # plus three guessed equations and one free equation
k = 6
t = [n]*k
seq = [4,5,5,5,4,4,4,2,3,2,3,3,3,2,2,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
for ind in seq:
print(t, m, ind)
if t[ind] < m:
if t[ind] == 0:
raise ValueError
t[ind] = 0
m -= 1
continue
elif t[ind] <= m+1:
t[ind] = 0
m -= 2
continue
else:
if ind == 2 or ind == 4 or m <= 3: # to speed up the forward step
t[ind] -= m
m -= 1
else:
t[ind] -= (m-1)
m -= 1

Final code for 6 keys#

Since x^2+x=? may have 0 or 2 solutions, the backward step should keep a list of solutions. Thus batch_compress_backward is added to the Transform class.

solve6.sage
from tqdm import tqdm
from dataclasses import dataclass
from functools import lru_cache
from concurrent.futures import ProcessPoolExecutor
import random, os
from uov import uov_1p_pkc as uov
F = GF(2**8, name='z', modulus=x^8 + x^4 + x^3 + x + 1)
z = F.gen()
R = PolynomialRing(F, 'xbar')
xbar = R.gen()
elms = [F.from_integer(i) for i in range(2**8)]
def gen(n, m):
U = random_matrix(F, n, n)
while U.det() == 0:
U = random_matrix(F, n, n)
pk = []
for _ in range(m):
M = random_matrix(F, n, n)
M[:m, :m] = 0
M = U.transpose() * M * U
pk.append(M)
return pk
def from_uov_vector(elms):
m = uov.m
bs = elms.to_bytes(m, 'big')
vs = [F.from_integer(i) for i in bs]
return vs
def from_uov(pk):
v = uov.v
m = uov.m
Ms = [[[0] * n for _ in range(n)] for _ in range(m)]
m1 = uov.unpack_mtri(pk, v)
m2 = uov.unpack_mrect(pk[uov.p1_sz:], v, m)
m3 = uov.unpack_mtri(pk[uov.p1_sz + uov.p2_sz:], m)
for i in range(v):
for j in range(i, v):
vec = from_uov_vector(m1[i][j])
for k in range(m):
Ms[k][i][j] = vec[k]
for i in range(v):
for j in range(m):
vec = from_uov_vector(m2[i][j])
for k in range(m):
Ms[k][i][j + v] = vec[k]
for i in range(m):
for j in range(i, m):
vec = from_uov_vector(m3[i][j])
for k in range(m):
Ms[k][i + v][j + v] = vec[k]
Ms = [matrix(F, M) for M in Ms]
return Ms
def to_uov(sol):
output = []
for s in sol:
output.append([x.to_integer() for x in s.list()])
output = [bytearray(x) for x in output]
return output
def F_sqrt(x):
return x.square_root()
quad_sols = {}
for u in elms:
for t in elms:
quad_sols[(u, t)] = []
for x in elms:
t = u * x + x**2
quad_sols[(u, t)].append(x)
def F_quad_solve(u, t):
# solve x^2 + u * x + t = 0
return quad_sols[(u, t)]
class EQ:
def __init__(self, Ms: list, v):
self.Ms = Ms
self.v = v
def evaluate(self, sigs):
result = 0
for M, sig in zip(self.Ms, sigs):
result += sig * M * sig
result += self.v
return result
def evaluate_M(self, sigs):
result = 0
for M, sig in zip(self.Ms, sigs):
result += sig * M * sig
return result
def compress(self, Ts):
new_Ms = []
for M, T in zip(self.Ms, Ts):
new_Ms.append(T.transpose() * M * T)
return EQ(new_Ms, self.v)
@property
def ns(self):
return [M.nrows() for M in self.Ms]
def __mul__(self, other):
other = F(other)
new_Ms = [other * M for M in self.Ms]
new_v = other * self.v
return EQ(new_Ms, new_v)
def __add__(self, other):
assert isinstance(other, EQ)
assert len(self.Ms) == len(other.Ms)
new_Ms = [M + other.Ms[i] for i, M in enumerate(self.Ms)]
new_v = self.v + other.v
return EQ(new_Ms, new_v)
def __rmul__(self, other):
other = F(other)
return self.__mul__(other)
class ProblemData:
def __init__(self, eqs: list[EQ]):
self.eqs = eqs
self.ns = eqs[0].ns
self.k = len(self.ns)
self.m = len(eqs)
for i in range(1, self.m):
assert eqs[i].ns == self.ns
def check(self, sigs):
vs = [eq.evaluate(sigs) for eq in self.eqs]
return all(v == 0 for v in vs)
def get_M(self, i, index):
return self.eqs[i].Ms[index]
def get_key(self, index):
Ms = [self.get_M(i, index) for i in range(self.m)]
return Ms
def compress(self, Ts):
new_eqs = [eq.compress(Ts) for eq in self.eqs]
return ProblemData(new_eqs)
class Transform:
def __init__(self):
pass
def forward(self, problem: ProblemData):
raise NotImplementedError
def compress(self, Ts):
raise NotImplementedError
def backward(self, x):
raise NotImplementedError
def compress_backward(self, x_compressed):
raise NotImplementedError
def batch_compress_backward(self, x_compressed_list):
raise NotImplementedError
class Transform0(Transform):
def __init__(self, index):
self.index = index
self.eq = None
def forward(self, problem: ProblemData):
Ms = problem.get_key(self.index)
Ms = [M + M.transpose() for M in Ms]
assert all(M[0].is_zero() for M in Ms)
m = len(Ms)
eqs = problem.eqs[:]
for i0 in range(m):
if Ms[i0][0, 0] != 0:
break
eqs[0], eqs[i0] = eqs[i0], eqs[0]
coeffs = [eqs[i].Ms[self.index][0, 0] for i in range(problem.m)]
eqs[0] = eqs[0] * coeffs[0].inverse()
for i in range(1, problem.m):
eqs[i] = eqs[i] + (-coeffs[i]) * eqs[0]
assert eqs[0].Ms[self.index][0, 0] == 1
for i in range(problem.m):
eqs[i].Ms[self.index] = eqs[i].Ms[self.index][1:, 1:]
self.eq = eqs.pop(0)
return ProblemData(eqs)
def compress(self, Ts):
self.compressed_eq = self.eq.compress(Ts)
new_Ts = Ts[:]
new_Ts[self.index] = block_matrix(F, [[identity_matrix(1), 0], [0, Ts[self.index]]])
return new_Ts
def backward(self, x):
u = self.eq.evaluate(x)
x0 = F_sqrt(u)
v0 = x[self.index]
v1 = vector(F, [x0] + v0.list())
x_new = x[:]
x_new[self.index] = v1
return x_new
def compress_backward(self, x_compressed):
u = self.compressed_eq.evaluate(x_compressed)
x0 = F_sqrt(u)
v0 = x_compressed[self.index]
v1 = vector(F, [x0] + v0.list())
x_new = x_compressed[:]
x_new[self.index] = v1
return x_new
def batch_compress_backward(self, x_compressed_list):
return [self.compress_backward(x) for x in x_compressed_list]
class Transform1(Transform):
def __init__(self, index, T):
self.index = index
self.T = T
def forward(self, problem):
return ProblemData([self.apply_T(eq) for eq in problem.eqs])
def apply_T(self, eq: EQ):
Ms = eq.Ms[:]
Ms[self.index] = self.T.transpose() * Ms[self.index] * self.T
return EQ(Ms, eq.v)
def compress(self, Ts):
new_Ts = Ts[:]
new_Ts[self.index] = self.T * Ts[self.index]
return new_Ts
def backward(self, x):
x0 = x[self.index]
x_new = x[:]
x_new[self.index] = self.T * x0
return x_new
def compress_backward(self, x_compressed):
return x_compressed
def batch_compress_backward(self, x_compressed_list):
return x_compressed_list[:]
class Transform2(Transform):
def __init__(self, index):
self.index = index
self.eq = None
self.pad = 0
def forward(self, problem: ProblemData):
Ms = problem.get_key(self.index)
m = len(Ms)
self.pad = Ms[0].nrows() - 1
eqs = problem.eqs[:]
for i0 in range(m):
if Ms[i0][0, 0] != 0:
break
eqs[0], eqs[i0] = eqs[i0], eqs[0]
coeffs = [eqs[i].Ms[self.index][0, 0] for i in range(problem.m)]
eqs[0] = eqs[0] * coeffs[0].inverse()
for i in range(1, problem.m):
eqs[i] = eqs[i] + (-coeffs[i]) * eqs[0]
assert eqs[0].Ms[self.index][0, 0] == 1
new_eqs = []
for i in range(problem.m):
eq = eqs[i]
Ms = eq.Ms[:]
Ms.pop(self.index)
new_eqs.append(EQ(Ms, eq.v))
self.eq = new_eqs.pop(0)
return ProblemData(new_eqs)
def compress(self, Ts):
self.compressed_eq = self.eq.compress(Ts)
new_T = matrix(F, [[1]] + [[0] for _ in range(self.pad)])
new_Ts = Ts[:self.index] + [new_T] + Ts[self.index:]
return new_Ts
def backward(self, x):
u = self.eq.evaluate(x)
x0 = F_sqrt(u)
v1 = vector(F, [x0] + [0] * self.pad)
x_new = x[:self.index] + [v1] + x[self.index:]
return x_new
def compress_backward(self, x_compressed):
u = self.compressed_eq.evaluate(x_compressed)
x0 = F_sqrt(u)
v1 = vector(F, [x0])
x_new = x_compressed[:self.index] + [v1] + x_compressed[self.index:]
return x_new
def batch_compress_backward(self, x_compressed_list):
return [self.compress_backward(x) for x in x_compressed_list]
class Transform3(Transform):
def __init__(self, index):
self.index = index
self.eq = None
self.u = None
def forward(self, problem: ProblemData):
Ms = problem.get_key(self.index)
m = len(Ms)
for i0 in range(m):
if Ms[i0][0, 0] != 0:
break
eqs = problem.eqs[:]
eqs[0], eqs[i0] = eqs[i0], eqs[0]
coeffs = [eqs[i].Ms[self.index][0, 0] for i in range(problem.m)]
eqs[0] = eqs[0] * coeffs[0].inverse()
for i in range(1, problem.m):
eqs[i] = eqs[i] + (-coeffs[i]) * eqs[0]
M_is = [eqs[i].Ms[self.index] for i in range(problem.m)]
for i in range(1, m):
assert M_is[i][0, 0] == 0
assert (M_is[i] + M_is[i].transpose())[0].is_zero()
self.u = (M_is[0]+M_is[0].transpose())[0][1:]
for i in range(problem.m):
eqs[i].Ms[self.index] = eqs[i].Ms[self.index][1:, 1:]
self.eq = eqs.pop(0)
return ProblemData(eqs)
def compress(self, Ts):
self.compressed_eq = self.eq.compress(Ts)
self.compressed_u = self.u * Ts[self.index]
new_Ts = Ts[:]
new_Ts[self.index] = block_matrix(F, [[identity_matrix(1), 0], [0, Ts[self.index]]])
return new_Ts
def compress_backward_2(self, x_compressed):
v0 = self.compressed_eq.evaluate(x_compressed)
u0 = self.compressed_u * x_compressed[self.index]
x0_sols = F_quad_solve(u0, v0)
sols = []
for x0 in x0_sols:
v1 = vector(F, [x0] + x_compressed[self.index].list())
x_new = x_compressed[:]
x_new[self.index] = v1
sols.append(x_new)
return sols
def batch_compress_backward(self, x_compressed_list):
ret = []
for x in x_compressed_list:
ret.extend(self.compress_backward_2(x))
return ret
def solve_T_even(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n+1:
raise ValueError
for e in elms:
M0 = Ms2[0] + e * Ms2[1]
if M0.det() != 0:
break
M1 = Ms2[1] * M0.inverse()
M2 = Ms2[2] * M0.inverse()
M3 = Ms2[3] * M0.inverse()
for e1 in elms:
for e2 in elms:
MM = M1 + e1 * M2 + e2 * M3
r = MM.charpoly().roots()
if len(r) == 0:
continue
r0 = r[0][0]
b = (MM-r0*identity_matrix(F, n)).left_kernel().basis()[0]
if b[0] == 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
Ms3 = [T*M*T.transpose() for M in Ms]
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms3[i] + Ms3[i].transpose()
C[i] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def solve_T_small(Ms):
n = Ms[0].nrows()
k = len(Ms)
if k >= n+1:
raise ValueError
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms[i] + Ms[i].transpose()
C[i] = M_tmp[0][1:]
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return T2.transpose()
def solve_T_odd(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n+1:
raise ValueError
M0 = Ms2[0]
M1 = Ms2[1]
M2 = Ms2[2]
for e1 in elms:
for e2 in elms:
MM = M0 + e1 * M1 + e2 * M2
b = MM.left_kernel().basis()[0]
if b[0] == 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
Ms3 = [T*M*T.transpose() for M in Ms]
C = zero_matrix(F, k, n-1)
for i in range(k):
M_tmp = Ms3[i] + Ms3[i].transpose()
C[i] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def solve_T_even_semi_linear(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n+1:
raise ValueError
for e0 in elms:
M0 = Ms2[0] + e0 * Ms2[1]
if M0.det() != 0:
break
M1 = Ms2[1] * M0.inverse()
M2 = Ms2[2] * M0.inverse()
M3 = Ms2[3] * M0.inverse()
for e1 in elms:
for e2 in elms:
MM = M1 + e1 * M2 + e2 * M3
r = MM.charpoly().roots()
if len(r) == 0:
continue
r0 = r[0][0]
U0 = r0*(Ms[0]+e0*Ms[1])+Ms[1]+e1 * Ms[2]+e2 * Ms[3]
for b in (MM-r0*identity_matrix(F, n)).left_kernel().basis():
if b[0] == 0:
continue
if b*U0*b != 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
Ms3 = [T*M*T.transpose() for M in Ms]
Ms3_copy = Ms3[:]
for i0 in range(k):
if Ms3_copy[i0][0, 0] != 0:
break
Ms3_copy[0], Ms3_copy[i0] = Ms3_copy[i0], Ms3_copy[0]
Ms3_copy[0] = Ms3_copy[0] * Ms3_copy[0][0, 0].inverse()
for i in range(1, k):
if Ms3_copy[i][0, 0] == 0:
continue
Ms3_copy[i] = Ms3_copy[i] - Ms3_copy[i][0, 0] * Ms3_copy[0]
C = zero_matrix(F, k-1, n-1)
for i in range(1, k):
M_tmp = Ms3_copy[i] + Ms3_copy[i].transpose()
C[i-1] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def solve_T_odd_semi_linear(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
if k >= n:
raise ValueError
M0 = Ms2[0]
M1 = Ms2[1]
M2 = Ms2[2]
for e1 in elms:
for e2 in elms:
MM = M0 + e1 * M1 + e2 * M2
b = MM.left_kernel().basis()[0]
if b[0] == 0:
continue
b = b / b[0]
T = identity_matrix(F, n)
T[0] = b
U0 = Ms[0] + e1 * Ms[1] + e2 * Ms[2]
if b*U0*b != 0:
continue
Ms3 = [T*M*T.transpose() for M in Ms]
Ms3_copy = Ms3[:]
for i0 in range(k):
if Ms3_copy[i0][0, 0] != 0:
break
Ms3_copy[0], Ms3_copy[i0] = Ms3_copy[i0], Ms3_copy[0]
Ms3_copy[0] = Ms3_copy[0] * Ms3_copy[0][0, 0].inverse()
for i in range(1, k):
if Ms3_copy[i][0, 0] == 0:
continue
Ms3_copy[i] = Ms3_copy[i] - Ms3_copy[i][0, 0] * Ms3_copy[0]
C = zero_matrix(F, k-1, n-1)
for i in range(1, k):
M_tmp = Ms3_copy[i] + Ms3_copy[i].transpose()
C[i-1] = M_tmp[0][1:]
if C.rank() >= n-1:
continue
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return (T2*T).transpose()
def solve_T_small_semi_linear(Ms):
Ms2 = [M + M.transpose() for M in Ms]
n = Ms2[0].nrows()
k = len(Ms2)
Ms3_copy = Ms[:]
for i0 in range(k):
if Ms3_copy[i0][0, 0] != 0:
break
Ms3_copy[0], Ms3_copy[i0] = Ms3_copy[i0], Ms3_copy[0]
Ms3_copy[0] = Ms3_copy[0] * Ms3_copy[0][0, 0].inverse()
for i in range(1, k):
if Ms3_copy[i][0, 0] == 0:
continue
Ms3_copy[i] = Ms3_copy[i] - Ms3_copy[i][0, 0] * Ms3_copy[0]
C = zero_matrix(F, k-1, n-1)
for i in range(1, k):
M_tmp = Ms3_copy[i] + Ms3_copy[i].transpose()
C[i-1] = M_tmp[0][1:]
bs = C.right_kernel().matrix()
T2 = block_matrix(F, [[identity_matrix(1),0], [0,bs]])
return T2.transpose()
def make_one_linear(problem: ProblemData, index):
ns = problem.ns
Ms = problem.get_key(index)
if problem.m <= 2:
T = solve_T_small(Ms)
elif ns[index] % 2 == 0:
T = solve_T_even(Ms)
else:
T = solve_T_odd(Ms)
if T is None:
raise ValueError
transform = Transform1(index, T)
problem = transform.forward(problem)
return problem, transform
def make_one_semi_linear(problem: ProblemData, index):
ns = problem.ns
Ms = problem.get_key(index)
if problem.m <= 3:
T = solve_T_small_semi_linear(Ms)
elif ns[index] % 2 == 0:
T = solve_T_even_semi_linear(Ms)
else:
T = solve_T_odd_semi_linear(Ms)
if T is None:
raise ValueError
transform = Transform1(index, T)
problem = transform.forward(problem)
return problem, transform
def eliminate(problem: ProblemData, index):
transform = Transform0(index)
problem = transform.forward(problem)
return problem, transform
def eliminate_semi_linear(problem: ProblemData, index):
transform = Transform3(index)
problem = transform.forward(problem)
return problem, transform
def remove_one_key(problem: ProblemData, index):
transform = Transform2(index)
problem = transform.forward(problem)
return problem, transform
n = 112
m = 40
names = ['Miku', 'Ichika', 'Minori', 'Kohane', 'Tsukasa', 'Kanade']
pks_uov = [uov.expand_pk(uov.shake256(name.encode(), 43576)) for name in names]
pks = [from_uov(pk) for pk in pks_uov]
message = b'SEKAI'
t = uov.shake256(message, 44)
target = [F.from_integer(i) for i in t]
eqs = [EQ(list(Ms), t) for Ms, t in zip(list(zip(*pks)), target)]
problem_orig = ProblemData(eqs[:])
# random shuffle
for i in range(256):
ind = random.randrange(1, len(eqs))
elm = random.choice(elms)
eqs[ind] = eqs[ind] + elm * eqs[0]
eqs[0], eqs[ind] = eqs[ind], eqs[0]
if i > 128 and eqs[0].v != 0:
break
last_eq = eqs.pop(0)
last_eq = last_eq * last_eq.v.inverse()
for i in range(len(eqs)):
eqs[i] = eqs[i] + (-eqs[i].v) * last_eq
problem0 = ProblemData(eqs[:m])
rest_eqs = eqs[m:]
def walk(problem, index):
print(problem.ns, problem.k, problem.m, index)
n = problem.ns[index]
m = problem.m
if n == 0:
raise ValueError
if n < m:
return [remove_one_key(problem, index)]
elif n > m+1:
if index != 2 and index != 4:
prob1, transform1 = make_one_semi_linear(problem, index)
prob2, transform2 = eliminate_semi_linear(prob1, index)
else:
prob1, transform1 = make_one_linear(problem, index)
prob2, transform2 = eliminate(prob1, index)
return [(prob1, transform1), (prob2, transform2)]
elif n == m+1 or n == m:
prob1, transform1 = make_one_linear(problem, index)
prob2, transform2 = eliminate(prob1, index)
prob3, transform3 = remove_one_key(prob2, index)
return [(prob1, transform1), (prob2, transform2), (prob3, transform3)]
seq = [4,5,5,5,4,4,4,2,3,2,3,3,3,2,2,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
path = []
problem = problem0
for ind in seq:
path.extend(walk(problem, ind))
problem = path[-1][0]
print(problem.ns, problem.k, problem.m, ind)
eq0 = problem.eqs[0]
assert eq0.v == 0
M = eq0.Ms[0]
M = M / M[0][0]
M0 = M[1:, 1:]
u0 = (M+M.transpose())[0][1:]
Ts = [identity_matrix(F, 5)]
for problem, transform in reversed(path):
Ts = transform.compress(Ts)
problem0_compressed = problem0.compress(Ts)
rest_eqs_compressed = [eq.compress(Ts) for eq in rest_eqs]
last_eq_compressed = last_eq.compress(Ts)
def try_solve():
x0 = random_vector(F, 4)
val = (x0 * M0 * x0)
u = u0 * x0
xsols = F_quad_solve(u, val)
if len(xsols) == 0:
return 0, None
sols = []
for i in range(len(xsols)):
x = vector(F, [xsols[i]] + x0.list())
sols.append([x])
for problem, transform in reversed(path):
if len(sols) == 0:
return 0, None
# assert problem.check(sol), (problem.ns, problem.k, problem.m, len(sol), [len(x) for x in sol], type(transform))
# sol = transform.backward(sol)
sols = transform.batch_compress_backward(sols)
# assert problem0.check(sol)
# assert problem0_compressed.check(sol)
for sol in sols:
find = True
for i, eq in enumerate(rest_eqs_compressed):
if eq.evaluate(sol) != 0:
find = False
if not find:
continue
r = last_eq_compressed.evaluate_M(sol)
if r == 0:
print("Sad...")
continue
v = last_eq_compressed.v
scale = F_sqrt(v/r)
sol = [scale * x for x in sol]
print("Found solution")
return len(sols), sol
return len(sols), None
pbar = tqdm()
def worker_init():
set_random_seed(os.getpid()+random.randint(0, 2**24))
with ProcessPoolExecutor(max_workers=12, initializer=worker_init) as executor:
tasks = []
for i in range(512):
tasks.append(executor.submit(try_solve))
while True:
task = tasks.pop(0)
cnt, sol = task.result()
if sol is not None:
break
pbar.update(cnt)
tasks.append(executor.submit(try_solve))
for task in tasks:
task.cancel()
pbar.close()
real_sol = [T*x for T, x in zip(Ts, sol)]
assert problem0.check(real_sol)
assert all(eq.evaluate(real_sol) == 0 for eq in rest_eqs)
assert problem_orig.check(real_sol)
sol = to_uov(real_sol)
print("".join(s.hex() for s in sol))
from pwn import xor
t = xor(*[uov.pubmap(s, pk) for s, pk in zip(sol, pks_uov)])
print(t.hex())
print(uov.shake256(message, 44).hex())

Conclusion#

My rating for the (not so) recent hard crypto is unfairy-ring > zerodaycrypto > lance-hard > flcg. The solution is a combination of many tricks and optimizations, which makes it quite complicated and hard to really solve it during a two-day CTF. But the key idea is still close to Cheng’s paper and can’t be further improved to 5 keys.

Neobeo has another independent code implementation written in C++, which is more efficient than my sage implementation. It’s very cool because I have no idea how to do GF(256) and these matrix operations in C++.

Finally, let’s return fairy-ring and break it with no reused keys.

fairy-ring sol

Footnotes#

  1. H. Miura, Y. Hashimoto, T. Takagi, Extended algorithm for solving underdefined multivariate quadratic equations

  2. C.M. Cheng, Y. Hashimoto, H. Miura, T. Takagi, A polynomial-time algorithm for solving a class of underdetermined multivariate quadratic equations over fields of odd characteristics 2 3

  3. H. Furue, S. Nakamura, T. Takagi, Improving Thomae-Wolf algorithm for solving underdetermined multivariate quadratic polynomial problem.

  4. Y. Hashimoto, An improvement of algorithms to solve under-defined systems of multivariate quadratic equations.