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
Then randomly set and solve the equations from to .
Introduction
from functools import reducefrom uov import uov_1p_pkc as uov # https://github.com/mjosaarinen/uov-py/blob/main/uov.pyimport osFLAG = 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 for an arbitrary quadratic function .
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:
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 , where . In a characteristic 2 field, the matrix 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 .
Lemma. The polar form is bilinear.
Expanding the polar form, we get
You can see that the polar form is bilinear. The matrix is symmetric and its diagonal is when over characteristic 2 field.
Prop. If the dimension of is odd, will be singular over characteristic 2 field.
Proof omitted.
Def. If we can split a quadratic form into two disjoint parts, i.e. , these two parts are orthogonal.
Def. A linear transformation of a quadratic form is given by , where is an arbitrary matrix. After the transformation, the quadratic form becomes . The new matrix is congruent to the original matrix .
We don’t ask to be invertible or square, so the transformation is only injective from to . As a result, the new quadratic form is a “subset” of the original quadratic form .
The last property of the characteristic 2 field is Frobenius endomorphism . 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 is a multivariate quadratic map if every component of is a quadratic form, i.e., .
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 efficiently.
We can rewrite UOV into equations of the form
Def. denotes a multivariate quadratic problem with equations and variables.
Then we can extend the definition to a ring signature scheme.
Def. denotes a ring signature scheme involving quadratic maps , s.t. , where .
This challenge can be formulated as an instance of with and , 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 problem, there’s a polynomial time algorithm to reduce it to , where .
The algorithm consists of three steps:
- Find a linear transformation such that in , the variables only appear in squared terms.
- Choose properly so that the equations become linear in .
- Apply Frobenius endomorphism to transform these equations into linear equations in .
Here I’ll formulate the steps in polar form.
Step 1
Firstly we’ll find a set of linear independent vectors such that
We can start with . For each , solve and get .
Extend to a complete basis and apply the transformation to the equations. As a result, the first variables in the first equations will be linear in squared terms. i.e. it could be written as
where is linear and is quadratic.
To ensure that satisfies the polar form conditions and is linearly independent of the first vectors, can’t be too large. Therefore, the condition is required.
Step 2
In the second step, we want to eliminate the linear terms . In our definition, quadratic forms don’t contain linear terms, hence we can directly set for . However, in the Thomae-Wolf algorithm, the linear terms are non-zero and it’s necessary to solve the equations and eliminate them.
To ensure the existence of a solution, must hold. Finally, we get
Step 3
In , we can raise it to power and obtain
After elimination, we get a problem. We need to ensure the step 1 and 2 has a solution, thus .
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 equations. This would cause a guessing complexity of 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
Although the proof is not rigorous, we can first fix the first rows of and it is likely to be linear independent, then estimate the probability of the remaining rows being linear dependent on the first rows. for each row, only entries are free, and the remaining entries are determined by the first rows. Therefore, the probability of each row being linear dependent on the first rows is . So ignoring the probability of the first rows being linear dependent, the probability of sampling a rank matrix is .
In most cases of a quadratic form, is much larger than , 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,
- Replace with , where is a constant.
- Apply a linear transformation . may be non-square and is a subspace of .
With these two operations, we can reduce the number of equations iteratively.
Lemma. can be reduced to .
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 , let’s say the columns of are . We can express the orthogonal condition by the polar form . Hence if we randomly set , the rest are all in the kernel of linear equations . Since we’ve got equations, the dimension of the kernel is at least . Note that always holds, so the largest subspace we can get is .
Once we have an orthogonal variable, we can do the first operation to eliminate the square terms . Finally, we will get
Now we can keep the last equations and keep reducing it. After finding a solution of , can be easily solved by and the Frobenius endomorphism ensures that always has a solution. The operations we’ve done don’t damage the orthogonal structure and only the first variables are affected during the reduction. Hence, the last equations is a problem.
Improving the reduction
In the previous reduction, the main cost comes from the equations . But these equations aren’t promised to be linear independent. Actually, the rank of the equations may be less than .
There’s two possible ways to lower the rank of the equations.
- Choose good .
- Randomly do many reductions and hope that the rank is less than .
Choosing good
The linear equations can be written as a matrix whose rows are . If we can find a linear combination of the rows to be zero, the rank of the matrix will be less than . For odd , the matrix is always singular, so simply letting be the kernel will work. For even , we have to do some combinations to find a singular matrix. Consider , Its determinant is a polynomial of . If we can find a root of the polynomial, the matrix will be singular.
With the rank minimizing trick, each reduction only costs variables now. But it’s still unlikely to find 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 , it is independent equations. After exculding the trivial solution , there’s variables in the equations. Hence the shape of the matrix is and we’ve shown that the probability of the rank being less than is . So only if is small, it is possible to sample a low rank matrix.
Lemma. can be reduced to .
This is the only case that guessing may get low rank matrix with prob .
Guessing equations
Before reduction, we can remove some equations and guess them later. Every equation has probability to match the target, so we can at most remove equations at first, which gives complexity.
7 keys
The whole reduction procedure can be described as follows:
n = 112m = 42 # plus two guessed equationsk = 7
t = [n]*kseq = [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 attempts.
Implementation & optimization
The backward step involves inverse linear transformation and solving . 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 times. Hence the matrix of 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 tqdmfrom dataclasses import dataclassfrom functools import lru_cachefrom concurrent.futures import ProcessPoolExecutorimport random, osfrom 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 = 112m = 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 shufflefor 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.vM = 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: breakpbar.close()
real_sol = [T*x for T, x in zip(Ts, sol)]assert problem0.check(real_sol)assert rest_eq0.evaluate(real_sol) == 0assert rest_eq1.evaluate(real_sol) == 0assert problem_orig.check(real_sol)sol = to_uov(real_sol)
from pwn import xort = 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 complexity. From Neobeo’s experiment which is written in c, it takes hour 20 cores to hit times, so hitting is not feasible for a two day CTF.
Extending to affine variable
The idea is that we can reduce . Here the coefficients of are not . However, it is still proportional to , so the elimination still works.
The equations now becomes , where is a constant. After eliminating , we get equations. Still we can choose good to remove one equation, but the requirement is more strict now. For the corresponding , is also required. The square terms are hard to control, so we can only guess it with probability .
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. can be reduced to .
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 . My idea is to get one free equation from the homogeneous property.
The homogeneous says that we can sign all simultaneously. If , we get 255 different non-zero signs of , that makes a free equation possible.
By doing at the beginning, we can let except for one equation. Therefore, only 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 = 112m = 40 # plus three guessed equations and one free equationk = 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 tqdmfrom dataclasses import dataclassfrom functools import lru_cachefrom concurrent.futures import ProcessPoolExecutorimport random, osfrom 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 = 112m = 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 shufflefor 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 == 0M = 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 xort = 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.
Footnotes
H. Miura, Y. Hashimoto, T. Takagi, Extended algorithm for solving underdefined multivariate quadratic equations ↩
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
H. Furue, S. Nakamura, T. Takagi, Improving Thomae-Wolf algorithm for solving underdetermined multivariate quadratic polynomial problem. ↩
Y. Hashimoto, An improvement of algorithms to solve under-defined systems of multivariate quadratic equations. ↩