<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Sceleri&apos;s Blog</title><description>No description</description><link>https://blog.sceleri.cc/</link><language>en</language><item><title>KalmarCTF 2026 Writeup - RBG+++</title><link>https://blog.sceleri.cc/posts/kalmar-ctf-2026/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/kalmar-ctf-2026/</guid><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is a writeup for a difficult crypto challenge from KalmarCTF 2026 called RBG+++.
Basically, it is an advanced version of lance-hard? from last year&apos;s KalmarCTF, so make sure you have read &lt;a href=&quot;https://adib.au/2025/lance-hard/&quot;&gt;Neobeo&apos;s writeup&lt;/a&gt; for lance-hard? before starting this one.&lt;/p&gt;
&lt;h2&gt;Description&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;RBG sometimes stands for Random Bit Generator. I didn&apos;t really like a challenge that was required to guess Dual_EC_DRBG.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This is a revenge challenge of &lt;a href=&quot;https://github.com/soon-haari/my-ctf-challenges/tree/main/daily-alpacahack-2025-12-30&quot;&gt;RBG&lt;/a&gt; from Daily Alpacahack.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import *

PBITS, NDAT = 137, 137

with open(&quot;flag.txt&quot;, &quot;rb&quot;) as f:
	m = int.from_bytes(f.read())

N = getPrime(PBITS) * getPrime(PBITS)
e = getRandomRange(731, N)
print(f&quot;{N = }&quot;)

lcg = lambda s: (s * 3 + 1337) % N

for i in range(NDAT):
	print(pow(m, e:=lcg(e), N) + pow(m, e:=lcg(e), N))

	# Internal audit
	e = getRandomRange(731, N)
	print(f&quot;[DEBUG] {e = }&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Analysis&lt;/h2&gt;
&lt;p&gt;The challenge first gives us a modulus $N$ which is a product of two 137-bit primes.
After that, it generates 136 pairs of $e, r$, where $r=m^e + m^{e&apos;} \pmod N$ and $e&apos; = 3e + 1337 \pmod N$.&lt;/p&gt;
&lt;p&gt;It is obvious that the modulus $N$ is not large enough, and we can easily factor it to get the two primes.
The main problem is that we can&apos;t directly find the roots of $r=m^e + m^{e&apos;} \pmod N$ even if we know the factorization of $N$ because $e$ is too large.&lt;/p&gt;
&lt;p&gt;Let&apos;s can first try to simplify the equation. Since $e&apos;$ is almost the triple of $e$, we can set $x=m^e$. If $e&amp;lt;\frac{N}{3}$, then we get&lt;/p&gt;
&lt;p&gt;$$
x+x^3m^{1337} \equiv r \pmod N
$$&lt;/p&gt;
&lt;p&gt;Although $m^{1337}$ is still unknown, it is a constant across all pairs. If we can find another relation between these equations, we can cancel out some variables and get a &quot;low&quot; degree univariate equation.&lt;/p&gt;
&lt;p&gt;But we don&apos;t want $z=m^{1337}$ to be the coefficient of $x^3$ because it is annoying to handle a non-monic polynomial.
So Let&apos;s first do some arrangements.
Let $N&amp;lt;3&lt;em&gt;e+1337&amp;lt;2&lt;/em&gt;N$, we have&lt;/p&gt;
&lt;p&gt;$$
m^e+m^{3e+1337-N} \equiv r \pmod N
$$&lt;/p&gt;
&lt;p&gt;which is equivalent to&lt;/p&gt;
&lt;p&gt;$$
m^{e+(1337-N)/2}+m^{3(e+(1337-N)/2)} \equiv r \cdot m^{(1337-N)/2} \pmod N
$$&lt;/p&gt;
&lt;p&gt;Now we can set $x=m^{e+(1337-N)/2}$ and $z=m^{(1337-N)/2}$, then it becomes $x^3+x\equiv r\cdot z \pmod N$.
We can solve the equations seperately on $\mod p$ and $\mod q$ to reduce the degree complexity.&lt;/p&gt;
&lt;p&gt;Similar to lance-hard?, the first step is to find small coefficients relations between $m^e$&apos;s.&lt;/p&gt;
&lt;h3&gt;Finding small relations&lt;/h3&gt;
&lt;p&gt;Since $m^{p-1} \equiv 1 \pmod p$, we can use LLL to find $\sum_{i=1}^k c_ie_i\equiv 0 \pmod{p-1}$ with small $c_i$&apos;s.
Thus we get&lt;/p&gt;
&lt;p&gt;$$
\prod_{i=1}^k (m^{e_i})^{c_i} \equiv 1 \pmod p
$$&lt;/p&gt;
&lt;p&gt;In addition, we can also add $z$ to the LLL because $z=m^{(1337-N)/2}$ is also a power of $m$. Then the equation becomes&lt;/p&gt;
&lt;p&gt;$$
\prod_{i=1}^k (m^{e_i})^{c_i} \cdot z^c \equiv 1 \pmod p
$$&lt;/p&gt;
&lt;p&gt;We can heuristically guess the degree of the final polynomial is proportional to $3^k \sum |c_i|$.
After some experiments, the best parameters are $k=8$ and there&apos;s exactly 4 positive $c_i$&apos;s and 4 negative $c_i$&apos;s, which balances the degree on both sides.&lt;/p&gt;
&lt;h3&gt;Reduce to univariate polynomial&lt;/h3&gt;
&lt;p&gt;Now we have 8 equations of $x^3+x\equiv r\cdot z \pmod p$ and one equation of the products of $x_i=m^{e_i}$ and $z$.&lt;/p&gt;
&lt;p&gt;A straightforward way is to use resultant to eliminate $x_i$ and get a univariate polynomial in $z$.
However, the computation of resultant is very expensive, and the degree of final polynomial will explode very fast.
By our previous heuristic guess, the degree will be about $2^{28}$, which is impossible to compute.
So we need to be more careful about how to eliminate variables.&lt;/p&gt;
&lt;p&gt;Remember that we have 4 positive $c_i$&apos;s and 4 negative $c_i$&apos;s, we can rewrite the product relation as&lt;/p&gt;
&lt;p&gt;$$
\begin{align*}
t&amp;amp;\equiv\prod_{i=1}^4 (m^{e_i})^{c_i} \cdot z^c \pmod p\
t&amp;amp;\equiv\prod_{i=5}^8 (m^{e_i})^{-c_i} \pmod p
\end{align*}
$$&lt;/p&gt;
&lt;p&gt;For each part, we first reduce it into a bivariate polynomial of $t$ and $z$, then use a resultant to eliminate $t$ and get the final result.&lt;/p&gt;
&lt;h3&gt;Algebraic Number Tricks&lt;/h3&gt;
&lt;p&gt;As we already know that $x_i^3+x_i\equiv r_i\cdot z \pmod p$, we can think of it as an &quot;algebraic number over $\mathbb{F}_p[z]$&quot;[^1]&lt;/p&gt;
&lt;p&gt;[^1]: Please be aware that the real definition of algebraic number is the root of a non-zero polynomial with coefficients over &lt;strong&gt;integers&lt;/strong&gt;.
But here we use it in finite field and the coefficients are polynomials of $z$.&lt;/p&gt;
&lt;p&gt;A famous result about algebraic numbers is that the sum, product of algebraic numbers is still an algebraic number.
Similarly, we can do the same thing in our case, which means $t$ is also an algebraic number over $\mathbb{F}_p[z]$.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
For a degree $d$ algebraic number $f(\alpha)=0$ over $\mathbb{F}_p[z]$, $t=\alpha^k$ is also a degree $d$ algebraic number.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let $M$ be the companion matrix of $f$, then $\alpha$ is the eigenvalue of $M$.
Thus $t=\alpha^k$ is the eigenvalue of $M^k$, whose characteristic polynomial is degree $d$ and has $t$ as a root.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
For two algebraic numbers $f(\alpha)=0$ and $g(\beta)=0$ over $\mathbb{F}_p[z]$, $t=\alpha\cdot\beta$ is also an algebraic number with degree $\deg f \cdot \deg g$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let the roots of $f$ be $\alpha_1,\alpha_2,...,\alpha_m$ and the roots of $g$ be $\beta_1,\beta_2,...,\beta_n$.[^2]
Then the polynomial $\prod_{ij}(x-\alpha_i\beta_j)$ has $t$ as a root and the degree is $mn$.&lt;/p&gt;
&lt;p&gt;[^2]: No need to worry if the roots exist in the field, these are just symbols over some unknown extension field.&lt;/p&gt;
&lt;p&gt;Note that the coefficients of $\prod_{ij}(x-\alpha_i\beta_j)$ are still in the field $\mathbb{F}_p[z]$ because they are symmetric polynomials of $\alpha_i$&apos;s and $\beta_j$&apos;s, thus it can be computed by the elementary symmetric polynomials, which is exactly the coefficients of $f$ and $g$.&lt;/p&gt;
&lt;p&gt;Now I&apos;ll introduce an efficient way to compute the coefficients of $\prod_{ij}(x-\alpha_i\beta_j)$.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
The power sums are defined as $p_k=\sum_{i=1}^m \alpha_i^k$ and $q_k=\sum_{j=1}^n \beta_j^k$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Clearly,&lt;/p&gt;
&lt;p&gt;$$
\sum_{ij}(\alpha_i\beta_j)^k=\sum_{i=1}^m \alpha_i^k \cdot \sum_{j=1}^n \beta_j^k=p_k\cdot q_k
$$&lt;/p&gt;
&lt;p&gt;On the other hand, we can quickly convert elementary symmetric polynomials from and to power sums using Newton&apos;s identities.
Therefore, we can compute the coefficients of $\prod_{ij}(x-\alpha_i\beta_j)$ in $O(mn)$ time. Since we only consider $m,n\leq 9$, this is much more efficient than any other fancy methods.&lt;/p&gt;
&lt;h3&gt;Exact degree prediction&lt;/h3&gt;
&lt;p&gt;Now we have a way to compute the polynomial of $t$ without computing any resultant.
The final polynomial of $t$ and $z$ is a degree 81 polynomial for $t$ with thousands degree for $z$, which is still impossible to compute resultant.&lt;/p&gt;
&lt;p&gt;Again, we can use the trick in lance-hard? to map $\mathbb{F}_p[z]$ to $\mathbb{F}_p$ by substituting $z$ with some random value, and interpolate later.&lt;/p&gt;
&lt;p&gt;Notice that all of these &quot;algebraic numbers tricks&quot; will not break the coefficients of polynomial, i.e. the coefficients are still polynomials of $z$.
Thus, the only thing we need to do is to approximate the degree of the final polynomial.&lt;/p&gt;
&lt;p&gt;Since the analysis is very hard, I just did some experiments to find the pattern of degree growth. The result shows that the degree of the final polynomial is about $3^kc+3^{k-1}\max{\sum_{i=1}^4 c_i, \sum_{i=5}^8 -c_i}$.
Now we can use it to choose the best LLL candidates and predict the degree of final polynomial, which is about $2^{25}$.&lt;/p&gt;
&lt;h3&gt;Solve the polynomial&lt;/h3&gt;
&lt;h4&gt;Fast Lagrange interpolation&lt;/h4&gt;
&lt;p&gt;Since we can free choose the evaluation points, we can use consecutive xs for interpolation. We can easily compute it in $O(n)$ time.[^3]&lt;/p&gt;
&lt;p&gt;[^3]: Thanks Neobeo for the implementation in lance-hard?, and check &lt;a href=&quot;https://codeforces.com/blog/entry/82953&quot;&gt;grhkm&apos;s blog&lt;/a&gt; for more details about the algorithm.&lt;/p&gt;
&lt;h4&gt;Finding roots of univariate polynomial&lt;/h4&gt;
&lt;p&gt;In general, the best method to find roots is first compute $gcd(f, z^p-z)$ and then factor the result.
However, the degree of our polynomial is about $2^{25}$, which is too large for NTL to run FFT, so it failed.&lt;/p&gt;
&lt;p&gt;Thus we have to compute two final polynomials and run gcd on them. Thanks to NTL and half-gcd and FFT(?), this time it doesn&apos;t raise error and finished in about 20 minutes.&lt;/p&gt;
&lt;p&gt;After getting the gcd, I find that it contains a large factor of $z^i$, so it could be faster if we remove the factors of $z^i$ before computing gcd and interpolation.&lt;/p&gt;
&lt;p&gt;The rest is simple as we have got $m^{(1337-N)/2}$.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;My final solution is written in sage mixed with pyx[^4]. The overall running time is&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLL: single thread, ~1 hour. This is not optimized at all.&lt;/li&gt;
&lt;li&gt;Compute many $z$&apos;s: multi-thread, ~2 hours per polynomial. We need 4 polynomials in total.&lt;/li&gt;
&lt;li&gt;Interpolation: single thread, ~50 minutes per polynomial.&lt;/li&gt;
&lt;li&gt;GCD: single thread, ~20 minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the total running time is about 10 hours, which is quite long but still acceptable for a hard crypto challenge.
It is still possible to optimize the code further, but I will leave it for future work.&lt;/p&gt;
&lt;p&gt;[^4]: I was surprised that sage supports &lt;code&gt;load(&quot;file.pyx&quot;)&lt;/code&gt;, which will automatically compile the pyx file and load it.&lt;/p&gt;
</content:encoded></item><item><title>SekaiCTF 2025 Writeup #2 - unfairy-ring</title><link>https://blog.sceleri.cc/posts/sekai-ctf-2025-writeup/unfairy-ring/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/sekai-ctf-2025-writeup/unfairy-ring/</guid><pubDate>Sun, 17 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Writeup for &lt;code&gt;unfairy-ring&lt;/code&gt; from SekaiCTF 2025.&lt;/p&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;TLDR:&amp;lt;/mark&amp;gt;
Following the approach proposed by Miura[^Miura] and Cheng[^Cheng], with some careful variable management, we can transform the equations into&lt;/p&gt;
&lt;p&gt;$$
\left{
\begin{align*}
x_1^2 + L_1(x_2, \cdots, x_n)x_1 + Q_1(x_2, \cdots, x_n) &amp;amp;= t_1 \
x_2^2 + L_2(x_3, \cdots, x_n)x_2 + Q_2(x_3, \cdots, x_n) &amp;amp;= t_2 \
&amp;amp;\vdots \
x_m^2 + L_m(x_{m+1}, \cdots, x_n)x_m + Q_m(x_{m+1}, \cdots, x_n) &amp;amp;= t_m \
\end{align*}
\right.
$$&lt;/p&gt;
&lt;p&gt;Then randomly set $x_{m+1}, \cdots, x_n$ and solve the equations from $x_m$ to $x_1$.&lt;/p&gt;
&lt;p&gt;[^Miura]: H. Miura, Y. Hashimoto, T. Takagi, Extended algorithm for solving underdefined multivariate quadratic equations
[^Cheng]: 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&lt;/p&gt;
&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;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(&quot;FLAG&quot;, &quot;SEKAI{TESTFLAG}&quot;)

def xor(a, b):
    assert len(a) == len(b), &quot;XOR inputs must be of the same length&quot;
    return bytes(x ^ y for x, y in zip(a, b))

names = [&apos;Miku&apos;, &apos;Ichika&apos;, &apos;Minori&apos;, &apos;Kohane&apos;, &apos;Tsukasa&apos;, &apos;Kanade&apos;]
pks = [uov.expand_pk(uov.shake256(name.encode(), 43576)) for name in names]
msg = b&apos;SEKAI&apos;

sig = bytes.fromhex(input(&apos;Ring signature (hex): &apos;))
assert len(sig) == 112 * len(names), &apos;Incorrect signature length&apos;

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), &apos;Invalid signature&apos;

print(FLAG)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This challenge is adapted from defund&apos;s &lt;a href=&quot;https://priv.pub/posts/dicectf-2025-quals/&quot;&gt;fairy-ring&lt;/a&gt;, 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+\delta)=y$ for an arbitrary quadratic function $f$.&lt;/p&gt;
&lt;p&gt;During DiceCTF, I was working on nil-circ and didn&apos;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.&lt;/p&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./pics/hint1.png&quot; alt=&quot;hint1&quot; /&gt;
&lt;img src=&quot;./pics/hint2.png&quot; alt=&quot;hint2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The actual solution is far more complicated than the hints and includes many tricks.
So in the writeup I&apos;ll try to start from the very beginning and explain how we finally get to the 6 keys solution step by step.&lt;/p&gt;
&lt;h2&gt;Preliminaries&lt;/h2&gt;
&lt;p&gt;Some mathematical tools we&apos;ll use later.&lt;/p&gt;
&lt;h3&gt;Quadratic Forms over Characteristic 2 Field&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
A &lt;strong&gt;quadratic form&lt;/strong&gt; is given by $f_{A}(\mathbf{x}) = \sum_{i, j}a_{ij}x_ix_j = \mathbf{x}^\top A\mathbf{x}$, where $A=(a_{ij})$.
In a characteristic 2 field, the matrix $M$ is not required to be symmetric.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here, we only consider the homogeneous quadratic forms because the signature scheme doesn&apos;t involve any linear terms.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
The &lt;strong&gt;polar form&lt;/strong&gt; of a quadratic form is $f&apos;&lt;em&gt;{A}(\mathbf{x}, \mathbf{y}) = f&lt;/em&gt;{A}(\mathbf{x}+\mathbf{y}) - f_{A}(\mathbf{x}) - f_{A}(\mathbf{y})$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Lemma.&amp;lt;/mark&amp;gt;
The &lt;strong&gt;polar form&lt;/strong&gt; is bilinear.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Expanding the polar form, we get&lt;/p&gt;
&lt;p&gt;$$
\begin{align*}
f&apos;&lt;em&gt;{A}(\mathbf{x}, \mathbf{y})
&amp;amp;= f&lt;/em&gt;{A}(\mathbf{x}+\mathbf{y}) - f_{A}(\mathbf{x}) - f_{A}(\mathbf{y}) \
&amp;amp;= (\mathbf{x}+\mathbf{y})^\top A (\mathbf{x}+\mathbf{y}) - \mathbf{x}^\top A \mathbf{x} - \mathbf{y}^\top A \mathbf{y} \
&amp;amp;= \mathbf{x}^\top A \mathbf{y} + \mathbf{y}^\top A \mathbf{x} \
&amp;amp;= \mathbf{x}^\top (A + A^\top) \mathbf{y}
\end{align*}
$$&lt;/p&gt;
&lt;p&gt;You can see that the polar form is bilinear.
The matrix $A + A^\top$ is symmetric and its diagonal is $0$ when over characteristic 2 field.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
If the dimension of $A$ is odd, $A + A^\top$ will be singular over characteristic 2 field.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Proof omitted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
If we can split a quadratic form into two disjoint parts, i.e. $$f_A(\mathbf{x})=f_B(\mathbf{x}_B)+f_C(\mathbf{x}_C)$$, these two parts are &lt;strong&gt;orthogonal&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
A &lt;strong&gt;linear transformation&lt;/strong&gt; of a quadratic form is given by $\mathbf{x} = T\mathbf{y}$, where $T$ is an arbitrary matrix.
After the transformation, the quadratic form becomes $$f_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&apos;=T^\top AT$$ is congruent to the original matrix $A$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We don&apos;t ask $T$ to be invertible or square, so the transformation is only injective from $\mathbf{y}$ to $\mathbf{x}$.
As a result, the new quadratic form $$f_{A&apos;}$$ is a &quot;subset&quot; of the original quadratic form $$f_A$$.&lt;/p&gt;
&lt;p&gt;The last property of the characteristic 2 field is &lt;strong&gt;Frobenius endomorphism&lt;/strong&gt; $F(x)=x^{2^r}$.
It is a linear map over characteristic 2 field and will be used in the Thomae Wolf&apos;s approach later.&lt;/p&gt;
&lt;h3&gt;Ring UOV Signature Scheme&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
Function $$\mathcal{F}\in\mathbb{F}^n\to\mathbb{F}^m$$ is a &lt;strong&gt;multivariate quadratic map&lt;/strong&gt; if every component of $$\mathcal{F}$$ is a quadratic form, i.e.,
$$\mathcal{F}(\mathbf{x})=(f_{A_1}(\mathbf{x}), \cdots, f_{A_m}(\mathbf{x}))$$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A detailed explanation of the UOV signature scheme can be found in &lt;a href=&quot;https://priv.pub/posts/rainbow-attack/&quot;&gt;defund&apos;s post&lt;/a&gt;.
Basically, UOV is a multivariate quadratic map with trapdoor that can solve equation $\mathcal{F}(\mathbf{x})=\mathbf{t}$ efficiently.&lt;/p&gt;
&lt;p&gt;We can rewrite UOV into equations of the form&lt;/p&gt;
&lt;p&gt;$$
\renewcommand{\vec}[1]{\mathbf{#1}}
f_{A_i}(\vec{x})=\vec{x}^\top A_i\vec{x}=t_i, 1\leq i \leq m
$$&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
$MQ^{m}(n)$ denotes a multivariate quadratic problem with $m$ equations and $n$ variables.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then we can extend the definition to a ring signature scheme.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Def.&amp;lt;/mark&amp;gt;
$MQ^{m}(n_1, \cdots, n_k)$ denotes a ring signature scheme involving $k$ quadratic maps $$\mathcal{F}&lt;em&gt;i\in\mathbb{F}^{n_i}\to\mathbb{F}^m$$,
s.t. $$\sum&lt;/em&gt;{i=1}^k \mathcal{F}_i(\mathbf{x}_i) = \mathbf{t}$$, where $\mathbf{x}_i\in\mathbb{F}^{n_i}$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This challenge can be formulated as an instance of $MQ^{m}(n, \cdots, n)$ with $k=6, n=112$ and $m=44$, and no trapdoor is present.&lt;/p&gt;
&lt;h3&gt;Thomae Wolf&apos;s Method&lt;/h3&gt;
&lt;p&gt;In the first hint, Neobeo gives two papers Furue et al.[^furue] and Hashimoto[^hashimoto] and both papers explains Thomae Wolf&apos;s idea.
I recommend reading this &lt;a href=&quot;https://www.pqcrypto2021.kr/download/program/1.2.1_hirokifurue_slide.pdf&quot;&gt;slide&lt;/a&gt; from PQCrypto 2021.&lt;/p&gt;
&lt;p&gt;It&apos;s not relevant to the actual solution, but I&apos;ve finished this section before the drama happened, so I&apos;ll keep it here.&lt;/p&gt;
&lt;p&gt;[^furue]: H. Furue, S. Nakamura, T. Takagi, Improving Thomae-Wolf algorithm for solving underdetermined multivariate quadratic polynomial problem.
[^hashimoto]: Y. Hashimoto, An improvement of algorithms to solve under-defined systems of  multivariate quadratic equations.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Theorem.&amp;lt;/mark&amp;gt; (Thomae Wolf)
For an underdetermined $MQ^m(n)$ problem, there&apos;s a polynomial time algorithm to reduce it to $MQ^{n-\alpha}(n-\alpha)$, where $\alpha=\lfloor\frac{n}{m}\rfloor-1$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The algorithm consists of three steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find a linear transformation $T$ such that in $$f_{M_i&apos;}, 1\leq i\leq \alpha$$, the variables $x_1, x_2, \cdots, x_m$ only appear in squared terms.&lt;/li&gt;
&lt;li&gt;Choose $x_{m+1},\cdots, x_n$ properly so that the equations become linear in $x_1^2, x_2^2, \cdots, x_m^2$.&lt;/li&gt;
&lt;li&gt;Apply Frobenius endomorphism to transform these equations into linear equations in $x_1, x_2, \cdots, x_m$.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here I&apos;ll formulate the steps in polar form.&lt;/p&gt;
&lt;h4&gt;Step 1&lt;/h4&gt;
&lt;p&gt;Firstly we&apos;ll find a set of linear independent vectors $\mathbf{v}_1,\mathbf{v}_2,\cdots,\mathbf{v}_m$ such that&lt;/p&gt;
&lt;p&gt;$$
f_{M_l}&apos;(\mathbf{v}_i, \mathbf{v}_j)=0, \forall 1\leq i&amp;lt;j\leq m, 1\leq l\leq\alpha
$$&lt;/p&gt;
&lt;p&gt;We can start with $\mathbf{v}_1=\mathbf{e}&lt;em&gt;1$.
For each $2\leq j\leq m$, solve $$f&lt;/em&gt;{M_l}&apos;(v_i, v_j)=0, 1\leq i\leq\alpha, l&amp;lt;j$$ and get $\mathbf{v}_j$.&lt;/p&gt;
&lt;p&gt;$$
T=(\mathbf{v}_1, \mathbf{v}_2, \cdots, \mathbf{v}_m)^\top
$$&lt;/p&gt;
&lt;p&gt;Extend $T$ to a complete basis and apply the transformation to the equations.
As a result, the first $m$ variables in the first $\alpha$ equations will be linear in squared terms. i.e. it could be written as&lt;/p&gt;
&lt;p&gt;$$
f_{M_l}=\sum_{i=1}^ma^{(l)}&lt;em&gt;{ii}x_i^2+\sum&lt;/em&gt;{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
$$&lt;/p&gt;
&lt;p&gt;where $L_i^{(l)}$ is linear and $Q^{(l)}$ is quadratic.&lt;/p&gt;
&lt;p&gt;To ensure that $\mathbf{v}_m$ satisfies the polar form conditions and is linearly independent of the first $m-1$ vectors, $\alpha$ can&apos;t be too large.
Therefore, the condition $n\geq\alpha (m-1)+m$ is required.&lt;/p&gt;
&lt;h4&gt;Step 2&lt;/h4&gt;
&lt;p&gt;In the second step, we want to eliminate the linear terms $L_i^{(l)}(x_{m+1},\cdots, x_n)$.
In our definition, quadratic forms don&apos;t contain linear terms, hence we can directly set $x_j=0$ for $j&amp;gt;m$ .
However, in the Thomae-Wolf algorithm, the linear terms are non-zero and it&apos;s necessary to solve the equations $L_i^{(l)}(x_{m+1},\cdots, x_n)=0$ and eliminate them.&lt;/p&gt;
&lt;p&gt;To ensure the existence of a solution, $n-m\geq\alpha m$ must hold. Finally, we get&lt;/p&gt;
&lt;p&gt;$$
\sum_{i=1}^ma^{(l)}_{ii}x_i=t_l&apos;, 1\leq l\leq \alpha
$$&lt;/p&gt;
&lt;h4&gt;Step 3&lt;/h4&gt;
&lt;p&gt;In $\mathbb{F}_{2^r}$, we can raise it to $2^{r-1}$ power and obtain&lt;/p&gt;
&lt;p&gt;$$
\sum_{i=1}^m\left(a^{(l)}_{ii}\right)^{2^{r-1}}x_i=\left({t_l&apos;}\right)^{2^{r-1}}
$$&lt;/p&gt;
&lt;p&gt;After elimination, we get a $MQ^{n-\alpha}(n-\alpha)$ problem.
We need $n-m\geq\alpha m$ to ensure the step 1 and 2 has a solution, thus $\alpha\leq\lfloor\frac{n}{m}\rfloor-1$.&lt;/p&gt;
&lt;h3&gt;Complexity of Guessing Analysis&lt;/h3&gt;
&lt;p&gt;In Furue&apos;s work, they improved Thomae Wolf&apos;s algorithm by adding a guessing step.
It is very helpful for improving the complexity of the algorithm.&lt;/p&gt;
&lt;p&gt;For example, we can ignore one equation and solve the remaining $m-1$ equations.
This would cause a guessing complexity of $\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.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
For a uniformly distributed matrix $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)}}.$&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Although the proof is not rigorous, we can first fix the first $m-k$ rows of $M$ and it is likely to be linear independent,
then estimate the probability of the remaining $k$ rows being linear dependent on the first $m-k$ rows.
for each row, only $m-k$ entries are free, and the remaining $n-m+k$ entries are determined by the first $m-k$ rows.
Therefore, the probability of each row being linear dependent on the first $m-k$ rows is $\frac{1}{256^{n-m+k}}$.
So ignoring the probability of the first $m-k$ rows being linear dependent, the probability of sampling a rank $m-k$ matrix is $\frac{1}{256^{k(n-m+k)}}$.&lt;/p&gt;
&lt;p&gt;In most cases of a quadratic form, $n$ is much larger than $m$, thus it&apos;s very unlikely to sample a low rank matrix.&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;p&gt;The solution is similar to the algorithm in Cheng&apos;s paper[^Cheng]. However, I wasn&apos;t aware of it until two months later when I was writing this writeup.
Luckily, it didn&apos;t become a full implement-paper challenge because the orthogonal structure of ring signature is needed to solve the 6 keys case.&lt;/p&gt;
&lt;h3&gt;Iterative reduction&lt;/h3&gt;
&lt;p&gt;We&apos;ve got two basic operations to reduce the equations,&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Replace $f_{A_i} = t_i$ with $f_{A_i}-cf_{A_j} = t_i - ct_j$, where $c$ is a constant.&lt;/li&gt;
&lt;li&gt;Apply a linear transformation $\mathbf{x} = T\mathbf{y}$. $T$ may be non-square and $\mathbf{y}$ is a subspace of $\mathbf{x}$.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;With these two operations, we can reduce the number of equations iteratively.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Lemma.&amp;lt;/mark&amp;gt;
$\text{MQ}^{m}(n_1, \cdots, n_k)$ can be reduced to $\text{MQ}^{m-1}(n_1-m-1, \cdots, n_k)$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The plan is to use linear transformation to make one variable orthogonal.
In general, it&apos;s impossible to make all equations orthogonal, so we must cost some variables to achieve that.&lt;/p&gt;
&lt;p&gt;For a linear transformation $T$, let&apos;s say the columns of $T$ are $\mathbf{t}_0, \cdots, \mathbf{t}&lt;em&gt;k$.
We can express the orthogonal condition by the polar form $f&lt;/em&gt;{A_i}&apos;(\mathbf{t}_0,\mathbf{t}_j)=0, 1\leq j\leq k$.
Hence if we randomly set $\mathbf{t}_0$, the rest $\mathbf{t}&lt;em&gt;j$ are all in the kernel of linear equations $f&lt;/em&gt;{A_i}&apos;(\mathbf{t}_0,\mathbf{t}&lt;em&gt;j)=0$.
Since we&apos;ve got $m$ equations, the dimension of the kernel is at least $n-m$.
Note that $f&lt;/em&gt;{A_i}&apos;(\mathbf{t}_0,\mathbf{t}_0)=0$ always holds, so the largest subspace we can get is $k=n-m-1$.&lt;/p&gt;
&lt;p&gt;Once we have an orthogonal variable, we can do the first operation to eliminate the square terms $x_0^2$.
Finally, we will get&lt;/p&gt;
&lt;p&gt;$$
\left{
\begin{align*}
x_0^2 + Q_1(x_1, \cdots) &amp;amp;= t_1 \
Q_2(x_1, \cdots) &amp;amp;= t_2 \
&amp;amp;\vdots \
Q_m(x_1, \cdots) &amp;amp;= t_m \
\end{align*}
\right.
$$&lt;/p&gt;
&lt;p&gt;Now we can keep the last $m-1$ equations and keep reducing it.
After finding a solution of $x_1, \cdots$, $x_0$ can be easily solved by $x_0^2 = t_1 - Q_1(x_1, \cdots)$ and the Frobenius endomorphism ensures that $x_1$ always has a solution.
The operations we&apos;ve done don&apos;t damage the orthogonal structure and only the first $n_1$ variables are affected during the reduction.
Hence, the last $m-1$ equations is a $MQ^{m-1}(n_1-m-1, n_2, \cdots, n_k)$ problem.&lt;/p&gt;
&lt;h3&gt;Improving the reduction&lt;/h3&gt;
&lt;p&gt;In the previous reduction, the main cost comes from the $m$ equations $f_{A_i}&apos;(\mathbf{t}_0,\mathbf{x})=0$.
But these equations aren&apos;t promised to be linear independent.
Actually, the rank of the equations may be less than $m$.&lt;/p&gt;
&lt;p&gt;There&apos;s two possible ways to lower the rank of the equations.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Choose good $\mathbf{t}_0$.&lt;/li&gt;
&lt;li&gt;Randomly do many reductions and hope that the rank is less than $m$.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;Choosing good $\mathbf{t}_0$&lt;/h4&gt;
&lt;p&gt;The linear equations can be written as a matrix whose rows are $\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 $m$.
For odd $n$, the matrix $(A_i + A_i^\top)$ is always singular, so simply letting $\mathbf{t}&lt;em&gt;0$ be the kernel will work.
For even $n$, we have to do some combinations to find a singular matrix.
Consider $(A_i + A_i^\top)+t(A_j + A_j^\top), t\in \mathbb{F}&lt;/em&gt;{256}$, Its determinant is a polynomial of $t$.
If we can find a root of the polynomial, the matrix will be singular.&lt;/p&gt;
&lt;p&gt;With the rank minimizing trick, each reduction only costs $m$ variables now.
But it&apos;s still unlikely to find $\mathbf{t}_0$ that&apos;s in the kernel of two matrices.&lt;/p&gt;
&lt;h4&gt;Guessing low rank matrix&lt;/h4&gt;
&lt;p&gt;What&apos;s the probability of sampling a low rank matrix?&lt;/p&gt;
&lt;p&gt;With a good $\mathbf{t}_0$, it is $m-1$ independent equations.
After exculding the trivial solution $\mathbf{t}_0$, there&apos;s $n-1$ variables in the equations.
Hence the shape of the matrix is $(m-1, n-1)$ and we&apos;ve shown that the probability of the rank being less than $m$ is $\frac{1}{256^{n-m+1}}$.
So only if $n-m$ is small, it is possible to sample a low rank matrix.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Lemma.&amp;lt;/mark&amp;gt;
$\text{MQ}^{m}(m, n_2, \cdots, n_k)$ can be reduced to $\text{MQ}^{m-1}(1, n_2, \cdots, n_k)$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is the only case that guessing may get low rank matrix with prob $\frac{1}{256}$.&lt;/p&gt;
&lt;h4&gt;Guessing equations&lt;/h4&gt;
&lt;p&gt;Before reduction, we can remove some equations and guess them later.
Every equation has $\frac{1}{256}$ probability to match the target, so we can at most remove $4$ equations at first, which gives $2^{32}$ complexity.&lt;/p&gt;
&lt;h3&gt;7 keys&lt;/h3&gt;
&lt;p&gt;The whole reduction procedure can be described as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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] &amp;lt; m:
        if t[ind] == 0:
            raise ValueError
        t[ind] = 0
        m -= 1
        continue
    elif t[ind] &amp;lt;= m+1:
        t[ind] = 0
        m -= 2
        continue
    else:
        t[ind] -= m
        m -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;seq&lt;/code&gt; is the sequence of key indices that we will reduce.
It&apos;s non-trivial to find the optimal sequence, so I just manually solved it.
We need to guess 2 equations, which is $2^{16}$ attempts.&lt;/p&gt;
&lt;h4&gt;Implementation &amp;amp; optimization&lt;/h4&gt;
&lt;p&gt;The backward step involves inverse linear transformation and solving $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 $m$ times.
Hence the matrix of $Q_1(x_1, \cdots)$ can be compressed into a smaller size.&lt;/p&gt;
&lt;p&gt;It is wrapped in a &lt;code&gt;Transform&lt;/code&gt; class, which has &lt;code&gt;compress&lt;/code&gt; method to compress the matrix when reduction and &lt;code&gt;compress_backward&lt;/code&gt; method to do the backward step with compressed matrix.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Transform:
    def __init__(self):
        pass

    def forward(self, problem: ProblemData):
        &quot;&quot;&quot;
        Reduce the problem to a smaller size.
        &quot;&quot;&quot;
        raise NotImplementedError
    
    def compress(self, Ts):
        &quot;&quot;&quot;
        Compress the matrix for the backward step.
        &quot;&quot;&quot;
        raise NotImplementedError

    def backward(self, x):
        &quot;&quot;&quot;
        Basic backward.
        &quot;&quot;&quot;
        raise NotImplementedError
    
    def compress_backward(self, x_compressed):
        &quot;&quot;&quot;
        Do the backward step with compressed matrix.
        &quot;&quot;&quot;
        raise NotImplementedError
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After compression, the backward step can reach 3000 iter/s with 16 cores in pure sage implementation.&lt;/p&gt;
&lt;h4&gt;Final code for 7 keys&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;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=&apos;z&apos;, modulus=x^8 + x^4 + x^3 + x + 1)
z = F.gen()
R = PolynomialRing(F, &apos;xbar&apos;)
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, &apos;big&apos;)
    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 &amp;gt;= 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() &amp;gt;= 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 &amp;gt;= 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 &amp;gt;= 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() &amp;gt;= 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 &amp;lt;= 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 = [&apos;Miku&apos;, &apos;Ichika&apos;, &apos;Minori&apos;, &apos;Kohane&apos;, &apos;Tsukasa&apos;, &apos;Kanade&apos;, &apos;Mai&apos;]
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&apos;shrooms&apos;, 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 &amp;gt; 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 &amp;lt; m:
        return [remove_one_key(problem, index)]
    elif n &amp;gt; 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(&quot;Found solution&quot;)
            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&apos;shrooms&apos;, 44).hex())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;More reduction tricks&lt;/h3&gt;
&lt;p&gt;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 $2^{40}$ complexity.
From Neobeo&apos;s experiment which is written in c, it takes $~1$ hour 20 cores to hit $2^{32}$ times, so hitting $2^{40}$ is not feasible for a two day CTF.&lt;/p&gt;
&lt;h4&gt;Extending to affine variable&lt;/h4&gt;
&lt;p&gt;The idea is that we can reduce $a_i(x_0^2+x_0)+Q_i(x_1,x_2,\cdots,x_n)$.
Here the coefficients of $x_0$ are not $0$. However, it is still proportional to $x_0^2$, so the elimination still works.&lt;/p&gt;
&lt;p&gt;The equations now becomes $f_{A_i}&apos;(\mathbf{t}_0,\mathbf{t}&lt;em&gt;0)=kf&lt;/em&gt;{A_i}(\mathbf{t}_0)$, where $k$ is a constant.
After eliminating $k$, we get $m-1$ equations.
Still we can choose good $\mathbf{t}_0$ to remove one equation, but the requirement is more strict now.
For the corresponding $(A+A^\top)\mathbf{t}&lt;em&gt;0=0$, $f&lt;/em&gt;{A}(\mathbf{t}_0)=0$ is also required.
The square terms are hard to control, so we can only guess it with probability $\frac{1}{256}$.&lt;/p&gt;
&lt;p&gt;Another problem is that only half of the equations &lt;code&gt;x^2+x=?&lt;/code&gt; 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&apos;s paper[^Cheng].&lt;/p&gt;
&lt;p&gt;Therefore, we derive the following reduction:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Lemma.&amp;lt;/mark&amp;gt;
$\text{MQ}^{m}(n_1, \cdots, n_k)$ can be reduced to $\text{MQ}^{m-1}(n_1-m+1, \cdots, n_k)$.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;Homogeneous&lt;/h4&gt;
&lt;p&gt;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&apos;s still not enough for my pure sage implementation.
So I came up with this idea.&lt;/p&gt;
&lt;p&gt;You may have noticed that the equations of UOV are homogeneous, so it has a nice property $f(ax)=a^2f(x)$.
My idea is to get one free equation from the homogeneous property.&lt;/p&gt;
&lt;p&gt;The homogeneous says that we can sign all $a^2\mathbf{t}$ simultaneously.
If $\mathbf{t}=0$, we get 255 different non-zero signs of $\mathbf{t}=0$, that makes a free equation possible.&lt;/p&gt;
&lt;p&gt;By doing $f_{A_i}-cf_{A_j} = t_i - ct_j$ at the beginning, we can let $t_i=0$ except for one equation.
Therefore, only $2^{24}$ attempts are needed to guess the rest 3 equations.&lt;/p&gt;
&lt;h3&gt;6 keys&lt;/h3&gt;
&lt;p&gt;Similar to 7 keys, the script shows the variable usage in the reduction procedure.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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] &amp;lt; m:
        if t[ind] == 0:
            raise ValueError
        t[ind] = 0
        m -= 1
        continue
    elif t[ind] &amp;lt;= m+1:
        t[ind] = 0
        m -= 2
        continue
    else:
        if ind == 2 or ind == 4 or m &amp;lt;= 3: # to speed up the forward step
            t[ind] -= m
            m -= 1
        else:
            t[ind] -= (m-1)
            m -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Final code for 6 keys&lt;/h4&gt;
&lt;p&gt;Since &lt;code&gt;x^2+x=?&lt;/code&gt; may have 0 or 2 solutions, the backward step should keep a list of solutions.
Thus &lt;code&gt;batch_compress_backward&lt;/code&gt; is added to the &lt;code&gt;Transform&lt;/code&gt; class.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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=&apos;z&apos;, modulus=x^8 + x^4 + x^3 + x + 1)
z = F.gen()
R = PolynomialRing(F, &apos;xbar&apos;)
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, &apos;big&apos;)
    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 &amp;gt;= 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() &amp;gt;= 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 &amp;gt;= 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 &amp;gt;= 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() &amp;gt;= 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 &amp;gt;= 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() &amp;gt;= 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 &amp;gt;= 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() &amp;gt;= 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 &amp;lt;= 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 &amp;lt;= 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 = [&apos;Miku&apos;, &apos;Ichika&apos;, &apos;Minori&apos;, &apos;Kohane&apos;, &apos;Tsukasa&apos;, &apos;Kanade&apos;]
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&apos;SEKAI&apos;
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 &amp;gt; 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 &amp;lt; m:
        return [remove_one_key(problem, index)]
    elif n &amp;gt; 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(&quot;Sad...&quot;)
            continue
        v = last_eq_compressed.v
        scale = F_sqrt(v/r)
        sol = [scale * x for x in sol]
        print(&quot;Found solution&quot;)
        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(&quot;&quot;.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())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;My rating for the (not so) recent hard crypto is unfairy-ring &amp;gt; &lt;a href=&quot;https://github.com/Neobeo/SekaiCTF2024/blob/main/writeup_zerodaycrypto.ipynb&quot;&gt;zerodaycrypto&lt;/a&gt; &amp;gt; &lt;a href=&quot;https://adib.au/2025/lance-hard/&quot;&gt;lance-hard&lt;/a&gt; &amp;gt; &lt;a href=&quot;https://github.com/ctf-gg/smileyctf-2025/tree/main/crypto/flcg&quot;&gt;flcg&lt;/a&gt;.
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&apos;s paper and can&apos;t be further improved to 5 keys.&lt;/p&gt;
&lt;p&gt;Neobeo has another independent code implementation written in C++, which is more efficient than my sage implementation.
It&apos;s very cool because I have no idea how to do GF(256) and these matrix operations in C++.&lt;/p&gt;
&lt;p&gt;Finally, let&apos;s return fairy-ring and break it with no reused keys.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;pics/sol.png&quot; alt=&quot;fairy-ring sol&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>SekaiCTF 2025 Writeup #1</title><link>https://blog.sceleri.cc/posts/sekai-ctf-2025-writeup/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/sekai-ctf-2025-writeup/</guid><pubDate>Sun, 17 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Writeups for crypto challenges from SekaiCTF 2025. &lt;code&gt;unfairy-ring&lt;/code&gt; will be in a separate post.&lt;/p&gt;
&lt;h2&gt;SSSS&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Shamir SendS the Secret to everyone&lt;/p&gt;
&lt;p&gt;Author: Utaha&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;import random, os

p = 2 ** 256 - 189
FLAG = os.getenv(&quot;FLAG&quot;, &quot;SEKAI{}&quot;)

def challenge(secret):
	t = int(input())
	assert 20 &amp;lt;= t &amp;lt;= 50, &quot;Number of parties not in range&quot;

	f = gen(t, secret)

	for i in range(t):
		x = int(input())
		assert 0 &amp;lt; x &amp;lt; p, &quot;Bad input&quot;
		print(poly_eval(f, x))

	if int(input()) == secret:
		print(FLAG)
		exit(0)
	else:
		print(&quot;:&amp;lt;&quot;)

def gen(degree, secret):
	poly = [random.randrange(0, p) for _ in range(degree + 1)]
	index = random.randint(0, degree)

	poly[index] = secret
	return poly

def poly_eval(f, x):
	return sum(c * pow(x, i, p) for i, c in enumerate(f)) % p

if __name__ == &quot;__main__&quot;:
	secret = random.randrange(0, p)
	for _ in range(2):
		challenge(secret)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;In general, Shamir needs &lt;code&gt;degree+1&lt;/code&gt; points to reconstruct the polynomial.
However if we have &lt;code&gt;g&lt;/code&gt; with order &lt;code&gt;t&lt;/code&gt;, the constant term overlaps with the &lt;code&gt;t&lt;/code&gt;-th term, so the polynomial can be reconstructed with &lt;code&gt;t&lt;/code&gt; points.
Notice that &lt;code&gt;(p-1)%29==0&lt;/code&gt;, so we can find &lt;code&gt;g&lt;/code&gt; with order &lt;code&gt;t=29&lt;/code&gt;.
Since we have two queries, secret is the intersection of the coefficients of the two polynomials.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from pwn import *

context.log_level = &apos;debug&apos;

p = 2 ** 256 - 189
R = PolynomialRing(GF(p), &apos;x&apos;)
t = 29

io = process([&quot;python3&quot;, &quot;chall.py&quot;])

def sample():
    io.sendline(str(t).encode())
    while True:
        g = randint(1, p)
        g = pow(g, (p-1)//t, p)
        if g != 1:
            break
    shares = []
    for i in range(t):
        x0 = pow(g, i, p)
        io.sendline(str(x0).encode())
        y0 = int(io.recvline().strip())
        shares.append((x0, y0))
    return R.lagrange_polynomial(shares).coefficients()

s0 = sample()
io.sendline(b&apos;1&apos;)
io.recvline()
s1 = sample()

for secret in set(s0) &amp;amp; set(s1):
    io.sendline(str(secret).encode())
    io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;I Dream of Genni&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;I had the strangest dream last night.&lt;/p&gt;
&lt;p&gt;Author: Neobeo&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from hashlib import sha256
from Crypto.Cipher import AES

x = int(input(&apos;Enter an 8-digit multiplicand: &apos;))
y = int(input(&apos;Enter a 7-digit multiplier: &apos;))
assert 1e6 &amp;lt;= y &amp;lt; 1e7 &amp;lt;= x &amp;lt; 1e8, &quot;Incorrect lengths&quot;
assert x * y != 3_81_40_42_24_40_28_42, &quot;Insufficient ntr-opy&quot;

def dream_multiply(x, y):
    x, y = str(x), str(y)
    assert len(x) == len(y) + 1
    digits = x[0]
    for a, b in zip(x[1:], y):
        digits += str(int(a) * int(b))
    return int(digits)
assert dream_multiply(x, y) == x * y, &quot;More like a nightmare&quot;

ct = &apos;75bd1089b2248540e3406aa014dc2b5add4fb83ffdc54d09beb878bbb0d42717e9cc6114311767dd9f3b8b070b359a1ac2eb695cd31f435680ea885e85690f89&apos;
print(AES.new(sha256(str((x, y)).encode()).digest(), AES.MODE_ECB).decrypt(bytes.fromhex(ct)).decode())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;A simple branch and prune is enough to solve this challenge.
We could use MITM to find larger pairs, but once the digits go beyond 10, it is nearly impossible to find a valid pair.&lt;/p&gt;
&lt;p&gt;If we accept &lt;code&gt;1*3=03&lt;/code&gt;, we can still find some larger pairs like &lt;code&gt;8827954385162 * 987958187368 = 8721649812532036435033616&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;algorithm&amp;gt;
#include &amp;lt;map&amp;gt;
#include &amp;lt;memory&amp;gt;
#include &amp;lt;cmath&amp;gt;
// g++ -O3 solve.cpp -o solve &amp;amp;&amp;amp; ./solve

typedef __int128 int128_t;
std::ostream &amp;amp;operator&amp;lt;&amp;lt;(std::ostream &amp;amp;os, int128_t val)
{
    std::string result;
    bool is_negative = val &amp;lt; 0;
    if (is_negative)
    {
        val *= -1;
    }

    do
    {
        result.push_back((val % 10) + &apos;0&apos;);
        val /= 10;
    } while (val != 0);

    if (is_negative)
    {
        result.push_back(&apos;-&apos;);
    }

    std::reverse(result.begin(), result.end());
    return (os &amp;lt;&amp;lt; result);
}

typedef std::pair&amp;lt;int64_t, int64_t&amp;gt; vec2;
typedef std::tuple&amp;lt;int64_t, int64_t, int128_t&amp;gt; vec3;
std::vector&amp;lt;vec2&amp;gt; pairs;

void gen1(int digits, std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; &amp;amp;result)
{
    if (digits == 1)
    {
        for (const auto &amp;amp;pair : pairs)
        {
            int64_t a = pair.first;
            int64_t b = pair.second;
            result-&amp;gt;emplace_back(a, b, a * b);
        }
        return;
    }

    int128_t mul = std::round(std::pow(10, digits - 1));
    int128_t mul1 = std::round(std::pow(10, digits));
    int128_t mul2 = mul * mul;
    if (mul % 10 != 0 || mul1 % 10 != 0 || mul2 % 10 != 0)
    {
        std::cerr &amp;lt;&amp;lt; &quot;Error: mul is not a multiple of 10&quot; &amp;lt;&amp;lt; std::endl;
        exit(1);
    }

    std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; result0 = std::make_shared&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt;();
    gen1(digits - 1, result0);
    for (const auto &amp;amp;item : *result0)
    {
        int64_t x = std::get&amp;lt;0&amp;gt;(item);
        int64_t y = std::get&amp;lt;1&amp;gt;(item);
        int128_t xy_mul = std::get&amp;lt;2&amp;gt;(item);
        for (const auto &amp;amp;pair : pairs)
        {
            int64_t a = pair.first;
            int64_t b = pair.second;
            int128_t x2 = a * mul + x;
            int128_t y2 = b * mul + y;
            int128_t xy_mul2 = xy_mul + (a * b) * mul2;
            if ((x2 * y2 - xy_mul2) % mul1 == 0)
            {
                result-&amp;gt;emplace_back(x2, y2, xy_mul2);
            }
        }
    }
}

void gen2(int digits, std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; &amp;amp;result)
{
    if (digits == 1)
    {
        for (const auto &amp;amp;pair : pairs)
        {
            int64_t a = pair.first;
            int64_t b = pair.second;
            for (int64_t i = 1; i &amp;lt; 10; i++)
            {
                int64_t x = 10 * i + a;
                int64_t y = b;
                result-&amp;gt;emplace_back(x, y, 100 * i + a * b);
            }
        }
        return;
    }

    std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; result0 = std::make_shared&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt;();
    gen2(digits - 1, result0);
    for (const auto &amp;amp;item : *result0)
    {
        int64_t x = std::get&amp;lt;0&amp;gt;(item);
        int64_t y = std::get&amp;lt;1&amp;gt;(item);
        int128_t xy_mul = std::get&amp;lt;2&amp;gt;(item);
        for (const auto &amp;amp;pair : pairs)
        {
            int64_t a = pair.first;
            int64_t b = pair.second;
            int128_t x2 = 10 * x + a;
            int128_t y2 = 10 * y + b;
            int128_t xy_mul2 = 100 * xy_mul + a * b;
            int128_t lower_bound = x2 * y2;
            int128_t upper_bound = (x2 + 1) * (y2 + 1);
            if (lower_bound &amp;lt;= xy_mul2 &amp;amp;&amp;amp; xy_mul2 &amp;lt; upper_bound)
            {
                result-&amp;gt;emplace_back(x2, y2, xy_mul2);
            }
        }
    }
}

#define M1 2
#define M2 3
#define M3 2

int main()
{
    for (int64_t i = 1; i &amp;lt; 10; i++)
        for (int64_t j = 1; j &amp;lt; 10; j++)
        {
            if (i * j &amp;gt;= 10)
            {
                pairs.push_back({i, j});
            }
        }

    std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; result1 = std::make_shared&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt;();
    std::shared_ptr&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt; result2 = std::make_shared&amp;lt;std::vector&amp;lt;vec3&amp;gt;&amp;gt;();
    gen1(M2 + M3, result1);

    std::cout &amp;lt;&amp;lt; &quot;Size1: &quot; &amp;lt;&amp;lt; result1-&amp;gt;size() &amp;lt;&amp;lt; std::endl;

    int128_t last_M3_digits = std::round(std::pow(10, M3));
    int128_t last_M3_digits2 = last_M3_digits * last_M3_digits;
    int128_t last_M2_digits = std::round(std::pow(10, M2));

    std::shared_ptr&amp;lt;std::map&amp;lt;uint64_t, std::vector&amp;lt;vec3&amp;gt;&amp;gt;&amp;gt; result_map = std::make_shared&amp;lt;std::map&amp;lt;uint64_t, std::vector&amp;lt;vec3&amp;gt;&amp;gt;&amp;gt;();
    for (const auto &amp;amp;item : *result1)
    {
        int64_t x = std::get&amp;lt;0&amp;gt;(item);
        int64_t y = std::get&amp;lt;1&amp;gt;(item);
        int128_t xy_mul = std::get&amp;lt;2&amp;gt;(item);
        uint64_t x2 = x / last_M3_digits;
        uint64_t y2 = y / last_M3_digits;
        uint64_t key = (x2 &amp;lt;&amp;lt; 32) | y2;
        if (result_map-&amp;gt;find(key) == result_map-&amp;gt;end())
        {
            (*result_map)[key] = std::vector&amp;lt;vec3&amp;gt;();
        }
        (*result_map)[key].emplace_back(x, y, xy_mul);
    }
    result1.reset();
    std::cout &amp;lt;&amp;lt; &quot;Finished processing map&quot; &amp;lt;&amp;lt; std::endl;

    gen2(M1 + M2, result2);
    std::cout &amp;lt;&amp;lt; &quot;Size2: &quot; &amp;lt;&amp;lt; result2-&amp;gt;size() &amp;lt;&amp;lt; std::endl;
    for (const auto &amp;amp;item : *result2)
    {
        int64_t x = std::get&amp;lt;0&amp;gt;(item);
        int64_t y = std::get&amp;lt;1&amp;gt;(item);
        int128_t xy_mul = std::get&amp;lt;2&amp;gt;(item);
        uint64_t x2 = x % last_M2_digits;
        uint64_t y2 = y % last_M2_digits;
        uint64_t key = (x2 &amp;lt;&amp;lt; 32) | y2;
        auto it = result_map-&amp;gt;find(key);
        if (it != result_map-&amp;gt;end())
        {
            for (const auto &amp;amp;pair : it-&amp;gt;second)
            {
                int64_t x1 = std::get&amp;lt;0&amp;gt;(pair);
                int64_t y1 = std::get&amp;lt;1&amp;gt;(pair);
                int128_t xy_mul1 = std::get&amp;lt;2&amp;gt;(pair);
                int128_t x_final = (int128_t)x * last_M3_digits + (int128_t)x1 % last_M3_digits;
                int128_t y_final = (int128_t)y * last_M3_digits + (int128_t)y1 % last_M3_digits;
                int128_t xy_mul_final = xy_mul * last_M3_digits2 + xy_mul1 % last_M3_digits2;
                if (x_final * y_final == xy_mul_final)
                {
                    std::cout &amp;lt;&amp;lt; &quot;Found: x=&quot; &amp;lt;&amp;lt; x_final &amp;lt;&amp;lt; &quot;, y=&quot; &amp;lt;&amp;lt; y_final &amp;lt;&amp;lt; &quot;, x*y=&quot; &amp;lt;&amp;lt; xy_mul_final &amp;lt;&amp;lt; std::endl;
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Literal Eval&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Trust me, it&apos;s 1000% safe to use literal eval in crypto!&lt;/p&gt;
&lt;p&gt;Author: Sceleri&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import bytes_to_long
from hashlib import shake_128
from ast import literal_eval
from secrets import token_bytes
from math import floor, ceil, log2
import os

FLAG = os.getenv(&quot;FLAG&quot;, &quot;SEKAI{}&quot;)
m = 256
w = 21
n = 128
l1 = ceil(m / log2(w))
l2 = floor(log2(l1*(w-1)) / log2(w)) + 1
l = l1 + l2

class WOTS:
    def __init__(self):
        self.sk = [token_bytes(n // 8) for _ in range(l)]
        self.pk = [WOTS.chain(sk, w - 1) for sk in self.sk]
    
    def sign(self, digest: bytes) -&amp;gt; list[bytes]:
        assert 8 * len(digest) == m
        d1 = WOTS.pack(bytes_to_long(digest), l1, w)
        checksum = sum(w-1-i for i in d1)
        d2 = WOTS.pack(checksum, l2, w)
        d = d1 + d2

        sig = [WOTS.chain(self.sk[i], w - d[i] - 1) for i in range(l)]
        return sig

    def get_pubkey_hash(self) -&amp;gt; bytes:
        hasher = shake_128(b&quot;\x04&quot;)
        for i in range(l):
            hasher.update(self.pk[i])
        return hasher.digest(16)

    @staticmethod
    def pack(num: int, length: int, base: int) -&amp;gt; list[int]:
        packed = []
        while num &amp;gt; 0:
            packed.append(num % base)
            num //= base
        if len(packed) &amp;lt; length:
            packed += [0] * (length - len(packed))
        return packed
    
    @staticmethod
    def chain(x: bytes, n: int) -&amp;gt; bytes:
        if n == 0:
            return x
        x = shake_128(b&quot;\x03&quot; + x).digest(16)
        return WOTS.chain(x, n - 1)

    @staticmethod
    def verify(digest: bytes, sig: list[bytes]) -&amp;gt; bytes:
        d1 = WOTS.pack(bytes_to_long(digest), l1, w)
        checksum = sum(w-1-i for i in d1)
        d2 = WOTS.pack(checksum, l2, w)
        d = d1 + d2

        sig_pk = [WOTS.chain(sig[i], d[i]) for i in range(l)]
        hasher = shake_128(b&quot;\x04&quot;)
        for i in range(len(sig_pk)):
            hasher.update(sig_pk[i])
        sig_hash = hasher.digest(16)
        return sig_hash

class MerkleTree:
    def __init__(self, height: int = 8):
        self.h = height
        self.keys = [WOTS() for _ in range(2**height)]
        self.tree = []
        self.root = self.build_tree([key.get_pubkey_hash() for key in self.keys])
    
    def build_tree(self, leaves: list[bytes]) -&amp;gt; bytes:
        self.tree.append(leaves)

        if len(leaves) == 1:
            return leaves[0]
        
        parents = []
        for i in range(0, len(leaves), 2):
            left = leaves[i]
            if i + 1 &amp;lt; len(leaves):
                right = leaves[i + 1]
            else:
                right = leaves[i]
            hasher = shake_128(b&quot;\x02&quot; + left + right).digest(16)
            parents.append(hasher)
        
        return self.build_tree(parents)

    def sign(self, index: int, digest: bytes) -&amp;gt; list:
        assert 0 &amp;lt;= index &amp;lt; len(self.keys)
        key = self.keys[index]
        wots_sig = key.sign(digest)
        sig = [wots_sig]
        for i in range(self.h):
            leaves = self.tree[i]
            u = index &amp;gt;&amp;gt; i
            if u % 2 == 0:
                if u + 1 &amp;lt; len(leaves):
                    sig.append((0, leaves[u + 1]))
                else:
                    sig.append((0, leaves[u]))
            else:
                sig.append((1, leaves[u - 1]))
        return sig
    
    @staticmethod
    def verify(sig: list, digest: bytes) -&amp;gt; bytes:
        wots_sig = sig[0]
        sig = sig[1:]
        pk_hash = WOTS.verify(digest, wots_sig)
        root_hash = pk_hash
        for (side, leaf) in sig:
            if side == 0:
                root_hash = shake_128(b&quot;\x02&quot; + root_hash + leaf).digest(16)
            else:
                root_hash = shake_128(b&quot;\x02&quot; + leaf + root_hash).digest(16)
        return root_hash

class Challenge:
    def __init__(self, h: int = 8):
        self.h = h
        self.max_signs = 2 ** h - 1
        self.tree = MerkleTree(h)
        self.root = self.tree.root
        self.used = set()
        self.before_input = f&quot;public key: {self.root.hex()}&quot;
    
    def sign(self, num_sign: int, inds: list, messages: list):
        assert num_sign + len(self.used) &amp;lt;= self.max_signs
        assert len(inds) == len(set(inds)) == len(messages) == num_sign
        assert self.used.isdisjoint(inds)
        assert all(b&quot;flag&quot; not in msg for msg in messages)
        sigs = []
        for i in range(num_sign):
            digest = shake_128(b&quot;\x00&quot; + messages[i]).digest(32)
            sigs.append(self.tree.sign(inds[i], digest))
        self.used.update(inds)
        return sigs

    def next(self):
        new_tree = MerkleTree(self.h)
        digest = shake_128(b&quot;\x01&quot; + new_tree.root).digest(32)
        index = next(i for i in range(2 ** self.h) if i not in self.used)
        sig = new_tree.sign(index, digest)
        self.tree = new_tree
        return {
            &quot;root&quot;: new_tree.root,
            &quot;sig&quot;: sig,
            &quot;index&quot;: index,
        }

    def verify(self, sig: list, message: bytes):
        digest = shake_128(b&quot;\x00&quot; + message).digest(32)
        for i, s in enumerate(reversed(sig)):
            if i != 0:
                digest = shake_128(b&quot;\x01&quot; + digest).digest(32)
            digest = MerkleTree.verify(s, digest)
        return digest == self.root

    def get_flag(self, sig: list):
        if not self.verify(sig, b&quot;Give me the flag&quot;):
            return {&quot;message&quot;: &quot;Invalid signature&quot;}
        else:
            return {&quot;message&quot;: f&quot;Congratulations! Here is your flag: {FLAG}&quot;}

    def __call__(self, type: str, **kwargs):
        assert type in [&quot;sign&quot;, &quot;next&quot;, &quot;get_flag&quot;]
        return getattr(self, type)(**kwargs)

challenge = Challenge()
print(challenge.before_input)
try:
    while True:
        print(challenge(**literal_eval(input(&quot;input: &quot;))))
except:
    exit(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;The challenge is a hash based signature scheme which supports infinite time signatures.
As the name suggests, the target is to use &lt;code&gt;literal_eval&lt;/code&gt; to craft malicious input.&lt;/p&gt;
&lt;p&gt;The most suspicious function is &lt;code&gt;sign&lt;/code&gt;, which signs many messages at once.
Additionally, you may specify the indexes of the WOTS keys to sign.
A straightforward idea is to somehow reuse the same key for multiple messages, hence the one-time signature is no longer secure.&lt;/p&gt;
&lt;p&gt;Although the &lt;code&gt;sign&lt;/code&gt; function does many checks to prevent this, the type hint of function parameters isn&apos;t enforced.
Hence, we can pass a dictionary with numerical keys. Since dict is also iterable, it won&apos;t raise an error and the keys will be used as indexes.
For example, &lt;code&gt;{i: 0 for i in range(255)}&lt;/code&gt; will generate 255 signatures with the same key.&lt;/p&gt;
&lt;p&gt;The rest is a simple WOTS key reuse attack.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from math import floor, ceil, log2
from Crypto.Util.number import bytes_to_long
import os
from hashlib import shake_128
from ast import literal_eval
from secrets import token_bytes
from pwn import *

def pack(num: int, length: int, base: int) -&amp;gt; list[int]:
    packed = []
    while num &amp;gt; 0:
        packed.append(num % base)
        num //= base
    if len(packed) &amp;lt; length:
        packed += [0] * (length - len(packed))
    return packed

def get_d(digest, m = 256, w = 21):
    l1 = ceil(m / log2(w))
    l2 = floor(log2(l1*(w-1)) / log2(w)) + 1
    l = l1 + l2
    d1 = pack(bytes_to_long(digest), l1, w)
    checksum = sum(w-1-i for i in d1)
    d2 = pack(checksum, l2, w)
    d = d1 + d2
    return d

io = process([&quot;python3&quot;, &quot;chall.py&quot;])
io.recvuntil(b&quot;public key:&quot;)
root = bytes.fromhex(io.recvline().strip().decode())
k = 255

def send(msg):
    io.recvuntil(b&quot;input:&quot;)
    io.sendline(str(msg).encode())
    ret = io.recvline().decode()
    if &quot;Traceback&quot; in ret:
        io.interactive()
    return literal_eval(ret)

msgs = [os.urandom(32) for _ in range(k)]
disgests = [shake_128(b&quot;\x00&quot; + msg).digest(32) for msg in msgs]
ds = [get_d(digest) for digest in disgests]
sigs = send({
    &quot;type&quot;: &quot;sign&quot;,
    &quot;num_sign&quot;: k,
    &quot;inds&quot;: {i: 0 for i in range(k)},
    &quot;messages&quot;: msgs,
})

target_digest = shake_128(b&quot;\x00&quot; + b&quot;Give me the flag&quot;).digest(32)
target = get_d(target_digest)

wots_sign = []
for i in range(len(target)):
    find = False
    for dd, sig in zip(ds, sigs):
        if dd[i] == target[i]:
            wots_sign.append(sig[0][i])
            find = True
            break
    assert find

forged_sig = [wots_sign] + sigs[0][1:]
print(send({
    &quot;type&quot;: &quot;get_flag&quot;,
    &quot;sig&quot;: [forged_sig],
}))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Alter Ego&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Even so, You alone should drown in blue and vanish.&lt;/p&gt;
&lt;p&gt;Before being consumed by those sorrow-filled eyes, divide the world by zero, humming with a hoarse voice.&lt;/p&gt;
&lt;p&gt;The finale&apos;s sound is ringing out.&lt;/p&gt;
&lt;p&gt;Author: kanon&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import *

from random import randint
import os

from montgomery_isogenies.kummer_line import KummerLine
from montgomery_isogenies.kummer_isogeny import KummerLineIsogeny

FLAG = os.getenv(&apos;flag&apos;, &quot;SEKAI{here_is_test_flag_hehe}&quot;).encode()
proof.arithmetic(False)

MI = 3
KU = 9
MIKU = 39

ells = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 587]
p = 4 * prod(ells) - 1

Fp = GF(p)
F = GF(p**2, modulus=x**2 + 1, names=&apos;i&apos;)
i = F.gen(0)
E0 = EllipticCurve(F, [1, 0])
E0.set_order((p + 1)**2)


def group_action(_C, priv, G):
    es = priv[:]
    while any(es):
        x = Fp.random_element()
        P = _C(x)
        A = _C.curve().a2()
        s = 1 if Fp(x ^ 3 + A * x ^ 2 + x).is_square() else -1

        S = [i for i, e in enumerate(es) if sign(e) == s and e != 0]
        k = prod([ells[i] for i in S])
        Q = int((p + 1) // k) * P
        for i in S:
            R = (k // ells[i]) * Q
            if R.is_zero():
                continue

            phi = KummerLineIsogeny(_C, R, ells[i])
            _C = phi.codomain()
            Q, G = phi(Q), phi(G)
            es[i] -= s
            k //= ells[i]

    return _C, G


def BEAM(base_alice_priv):
    alice_priv = base_alice_priv

    pub = 0

    for _ in range(MIKU):

        E = EllipticCurve(F, [0, pub, 0, 1, 0])
        omae_E = KummerLine(E)
        G = E.random_point()
        _G = omae_E(G)

        _final_E1, _final_G = group_action(omae_E, alice_priv, _G)
        _final_G = _final_G
        print(f&quot;final_a2 = {_final_E1.curve().a2()}&quot;)
        print(f&quot;{_final_G=}&quot;)

        omae_priv = list(map(int, input(&quot;your priv &amp;gt;&quot;).split(&quot;, &quot;)))

        assert all([abs(pi) &amp;lt; 2 for pi in omae_priv])
        assert len(omae_priv) == len(ells)

        alice_priv = [ai + yi for ai, yi in zip(alice_priv, omae_priv)]
        print(&quot;updated&quot;)

        pub = _final_E1.curve().a2()
    print(&quot;FIN!&quot;)


if __name__ == &quot;__main__&quot;:

    print(&quot;And now, it&apos;s time for the moment you&apos;ve been waiting for!&quot;)

    alice_priv = [randint(MI + KU, MI * KU) for _ in ells]
    BEAM(alice_priv)

    alter_ego = list(map(int, input(&apos;ready?! here is the &quot;alter ego&quot; &amp;gt;&apos;).split(&quot;, &quot;)))

    assert alice_priv != alter_ego
    assert len(alice_priv) == len(alter_ego)
    assert all([-MI * KU &amp;lt;= ai &amp;lt; 0 for ai in alter_ego])

    _E0 = KummerLine(E0)
    G = E0.random_point()
    _G = _E0(G)

    _alter_ego_E1, _ = group_action(_E0, alter_ego, _G)
    _alice_E1, __ = group_action(_E0, alice_priv, _G)

    if _alter_ego_E1.curve().a2() == _alice_E1.curve().a2():
        print(&quot;There you are... I&apos;ve been waiting and waiting for you to come to me.&quot;)
        print(FLAG)
    else:
        print(&quot;YOU CANT FIND MY ALTER EGO....&quot;)
        exit()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;Every $G\in E(\mathbb{F}_{p^2})$ can be decomposed into $G = P + Q$, where $P\in E(\mathbb{F}_p)$ and $Q$ lies on the twist of $E(\mathbb{F}_p)$.
The $l$-isogeny in function &lt;code&gt;group_action&lt;/code&gt; can reduce the order of either $P$ or $Q$ by a factor of $l$ depending on the sign.&lt;/p&gt;
&lt;p&gt;For any odd prime $l\mid p+1$, the order of $P$ can&apos;t have factor $l$ if the sign in &lt;code&gt;alice_priv&lt;/code&gt; is positive.
This indicates the sign of &lt;code&gt;alice_priv&lt;/code&gt;. Hence, we can keep sending $-1$ and observe the order of $P$ and $Q$.&lt;/p&gt;
&lt;p&gt;After retrieving &lt;code&gt;alice_priv&lt;/code&gt;, the next step is to find a negative priv that walks to the same curve.
The idea is the same as &lt;code&gt;su_auth&lt;/code&gt; from SUCTF 2025. So simply &lt;code&gt;[e-36 for e in alice_priv]&lt;/code&gt; passes the check.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from pwn import *
from tqdm import tqdm

MI = 3
KU = 9
MIKU = 39

ells = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 587]
p = 4 * prod(ells) - 1

Fp = GF(p)
F = GF(p**2, modulus=[1, 0, 1], names=&apos;i&apos;)
i = F.gen(0)

E0 = EllipticCurve(F, [0, 0, 0, 1, 0])

io = process([&apos;sage&apos;, &apos;chall.sage&apos;])

def group_action(E0, priv):
    E = E0
    es = priv[:]
    while any(es):
        x = Fp.random_element()
        P = E.lift_x(x)
        s = 1 if P[1] in Fp else -1
        S = [i for i, e in enumerate(es) if sign(e) == s and e != 0]
        k = prod([ells[i] for i in S])
        Q = ((p + 1) // k) * P
        
        for i in S:
            R = (k // ells[i]) * Q
            if R.is_zero():
                continue
            phi = E.isogeny(R)
            E = phi.codomain()
            Q = phi(Q)
            es[i] -= s
            k //= ells[i]
    return E

def read_curve():
    io.recvuntil(b&apos;final_a2 = &apos;)
    a2 = int(io.recvline().strip())
    E2 = EllipticCurve(F, [0, a2, 0, 1, 0])
    E2.set_order((p + 1)**2)
    return E2

def read_point(EC):
    io.recvuntil(b&apos;_final_G=&apos;)
    point_str = io.recvline().strip().decode()
    x, z = eval(point_str.replace(&apos;:&apos;, &apos;, &apos;))
    x = F(x)
    z = F(z)
    G = EC.lift_x(x / z)
    return G

def get_orders(G):
    EC = G.curve()
    gen1 = gen2 = None
    while gen1 is None or gen2 is None:
        x0 = Fp.random_element()
        P = EC.lift_x(x0)
        is_gen = True
        for ell in ells:
            P1 = ((p + 1) // ell) * P
            if P1.is_zero():
                is_gen = False
                break
        if not is_gen:
            continue
        if P.y() in Fp:
            gen1 = P
        else:
            gen2 = P
    pairing1 = G.weil_pairing(gen1, p+1)
    pairing2 = G.weil_pairing(gen2, p+1)
    return pairing2.multiplicative_order(), pairing1.multiplicative_order()

orders = []
curves = []

for _ in tqdm(range(MIKU)):
    cur_E = read_curve()
    curves.append(cur_E)
    G = read_point(cur_E)
    orders.append(get_orders(G))
    io.sendline(&quot;, &quot;.join([&quot;-1&quot;]*len(ells)).encode())
io.recvuntil(b&quot;FIN!\n&quot;)

guess_priv = []

for ell in ells:
    left = MI + KU
    right = MI * KU
    for i, (o1, o2) in enumerate(orders):
        if o1 % ell == 0:
            right = min(right, i)
        if o2 % ell == 0:
            left = max(left, i)
    guess_priv.append((left, right-left))

print(&quot;guess_priv =&quot;, guess_priv)

E1 = curves[0]
G0 = E0.random_point()

left, offset = zip(*guess_priv)
left = list(left)
offset = list(offset)
print(offset)

def gen_all_possible_privs(offset):
    if len(offset) == 1:
        for i in range(offset[0]+1):
            yield [i]
    else:
        for os in gen_all_possible_privs(offset[1:]):
            for i in range(offset[0]+1):
                yield [i] + os

E1_base = group_action(E0, left)

def check():
    for os in gen_all_possible_privs(offset):
        E1_new = group_action(E1_base, os).montgomery_model()
        # print(E1_new.j_invariant(), E1.j_invariant(), E1_new, os)
        if E1_new.j_invariant() == E1.j_invariant():
            real_priv = [l + o for l, o in zip(left, os)]
            print(&quot;Found valid private key:&quot;, real_priv)
            return real_priv

real_priv = check()
real_priv2 = [x - 36 for x in real_priv]
io.sendline(&quot;, &quot;.join(map(str, real_priv2)).encode())

io.recvuntil(b&apos;There you are...&apos;)
io.recvline()
print(io.recvline().strip().decode())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;APES&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;The Advanced Permutation Encryption Standard (APES) offers 512-bit security for encrypting your permutations.&lt;/p&gt;
&lt;p&gt;Author: Neobeo&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;import os
FLAG = os.getenv(&quot;FLAG&quot;, &quot;SEKAI{TESTFLAG}&quot;)

def play():
    plainperm = bytes.fromhex(input(&apos;Plainperm: &apos;))
    assert sorted(plainperm) == list(range(256)), &quot;Invalid permutation&quot;

    key = os.urandom(64)
    def f(i):
        for k in key[:-1]:
            i = plainperm[i ^ k]
        return i ^ key[-1]

    cipherperm = bytes(map(f, range(256)))
    print(f&apos;Cipherperm: {cipherperm.hex()}&apos;)
    print(&apos;Do you know my key?&apos;)
    return bytes.fromhex(input()) == key

if __name__ == &apos;__main__&apos;:
    # if you&apos;re planning to brute force anyway, let&apos;s prevent excessive connections
    while not play():
        print(&apos;Bad luck, try again.&apos;)
    print(f&apos;APES TOGETHER STRONG! {FLAG}&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;This is a super tiny block cipher with block size 8 bits.
The encyrption function consists of multiple rounds of substitution and round key addition.
We can choose the substitution table and need to find an &lt;strong&gt;unique&lt;/strong&gt; key.&lt;/p&gt;
&lt;p&gt;Theoretically, if we choose a random permutation, we get about 1600 bits of information. But random permutation also makes it hard to find the key.
So we need a more structured permutation and linear mapping seems to be a good choice.&lt;/p&gt;
&lt;p&gt;Let&apos;s say we use a linear mapping $$f(x) = ax, x\in\mathbb{F}&lt;em&gt;{256}$$, where $a$ is a generator of the field.
Since xor is the same as addition, after the encryption, we can get $$enc(x)=a^{63}x + \sum&lt;/em&gt;{i=0}^{63} a^{63-i}k_i$$.
As we can see, the only information we get is $\sum_{i=0}^{63} a^{63-i}k_i$, which isn&apos;t enough to recover the key.&lt;/p&gt;
&lt;p&gt;The solution is simple, we can add some error to the linear mapping.
For a 3 cycle permutation $g=(0, 1, 255)$[^gen], only three numbers are changed, so $f\circ g=g&apos;\circ f, f=ax+b$ and $g&apos;$ is another 3 cycle permutation.
Therefore,&lt;/p&gt;
&lt;p&gt;[^gen]: The cycle $=(0, 1, 255)$ is carefully chosen to ensure ${a^i, 255\times a^i, 254\times a^i| i=0,1\cdots,63}$ in $\mathbb{F}_{256}$ are all distinct.&lt;/p&gt;
&lt;p&gt;$$
f_1\circ g_1\circ f_2\circ\cdots\circ g_{63}\circ f_{64}=g_1&apos;\circ g_2&apos;\circ\cdots\circ g_{63}&apos;\circ F
$$&lt;/p&gt;
&lt;p&gt;where $F=a^{63}x+b$ and $f_i$ are linear.&lt;/p&gt;
&lt;p&gt;Since these 3-cycles can affect at most $192$ numbers, we can recover $F$ using the remaining $64$ numbers.
The problem is then reduced to decomposing the permutation into 3-cycles.
We can simplify it by choosing a special case where every 3-cycle only make the cycle &quot;larger&quot;.
This means that each time we add a 3-cycle, the cycle won&apos;t be split into smaller cycles.
With this property, we can ensure that these cycles don&apos;t contain the numbers that satisfy the linear mapping $F$.
Hence, we can filter all possible $k_i$ and enumerate to find the real key.&lt;/p&gt;
&lt;p&gt;The average probability of success is about $1$ in $60$ keys.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from pwn import *
from tqdm import tqdm
from collections import Counter

io = process([&apos;python3&apos;, &apos;chall.py&apos;])

F = GF(2**8, &apos;a&apos;)
a = F.gen()

perm1 = []
for i in range(256):
    x = F.from_integer(i)
    y = a * x
    perm1.append(y.to_integer())
perm1[0], perm1[1], perm1[255] = perm1[1], perm1[255], perm1[0]

def fail():
    io.sendline(b&apos;00&apos;)
    io.recvuntil(b&apos;Bad luck, try again.&apos;)

def try_solve():
    io.sendlineafter(b&apos;Plainperm: &apos;, bytes(perm1).hex().encode())
    io.recvuntil(b&apos;Cipherperm: &apos;)
    cipherperm = bytes.fromhex(io.recvline().strip().decode())
    cipherperm_F = [F.from_integer(x) for x in cipherperm]

    cipherperm_F = [x / (a**63) for x in cipherperm_F]
    for b in range(257):
        assert b &amp;lt; 256, &quot;This should not happen&quot;
        b0 = F.from_integer(b)
        cipherperm_F2 = [(x+b0).to_integer()+1 for x in cipherperm_F]
        if len(Permutation(cipherperm_F2).fixed_points()) &amp;gt; 20:
            break
    
    perm2 = Permutation(cipherperm_F2)
    cycles = perm2.cycle_tuples()
    cycles = [cycle for cycle in cycles if len(cycle) &amp;gt; 1]
    u = sum([(len(cycle)-1)//2 for cycle in cycles])
    
    if u != 63 or max([len(cycle) for cycle in cycles]) &amp;gt; 51:
        fail()
        return
    
    cycles = [tuple(F.from_integer(x-1) for x in cycle) for cycle in cycles]
    print(([len(cycle) for cycle in cycles]), u)

    all_numbers = [F.from_integer(i) for i in range(256)]
    possible_k0 = [all_numbers[:] for _ in range(63)]
    
    def check(us):
        for cycle in cycles:
            if all(u in cycle for u in us):
                inds = [cycle.index(u) for u in us]
                inversions = 0
                if inds[0] &amp;gt; inds[1]: inversions += 1
                if inds[0] &amp;gt; inds[2]: inversions += 1
                if inds[1] &amp;gt; inds[2]: inversions += 1
                if inversions % 2 == 1:
                    return False
                return True
        return False
    
    for i in range(63):
        filtered = []
        a0 = a ** i
        ts = [F.from_integer(t)/a0 for t in [0, 1, 255]]

        for k0 in possible_k0[i]:
            us = [u + k0 for u in ts]
            if check(us):
                filtered.append(k0)
        possible_k0[i] = filtered

    def update():
        all_pos = sum([list(cycle) for cycle in cycles], [])
        c = {pos: [] for pos in all_pos}

        for i in range(63):
            a0 = a ** i
            ts = [F.from_integer(t)/a0 for t in [0, 1, 255]]
            for k0 in possible_k0[i]:
                us = [u + k0 for u in ts]
                for u in us:
                    c[u].append((i, k0))

        for pos in c:
            if len(c[pos]) == 1:
                i, k0 = c[pos][0]
                possible_k0[i] = [k0]
    
    for _ in range(16):
        update()
    
    if prod([len(x) for x in possible_k0]) &amp;gt; 2**14:
        fail()
        return

    print([len(x) for x in possible_k0], prod([len(x) for x in possible_k0]).bit_length())

    possible_maps = [[] for _ in range(63)]
    for i in range(63):
        a0 = a ** i
        ts = [F.from_integer(t)/a0 for t in [0, 1, 255]]
        for k0 in possible_k0[i]:
            us = [u + k0 for u in ts]
            us = [x.to_integer() + 1 for x in us]
            perm3 = Permutation([tuple(us)])
            possible_maps[i].append((k0, perm3))

    def search(cur_map, i):
        if i == 63:
            if cur_map == perm2:
                return [[]]
            else:
                return None
        
        all_results = []
        for k0, perm3 in possible_maps[i]:
            new_map = cur_map * perm3
            result = search(new_map, i + 1)
            if result is not None:
                for r in result:
                    all_results.append([k0] + r)
        
        if all_results:
            return all_results
    
    pathes = search(cur_map=Permutation(list(range(1, 257))), i=0)
    if pathes is None or len(pathes) == 0:
        fail()
        return

    print(len(pathes), pathes)
    path = pathes[0]

    real_ks = []
    a0 = 1
    c0 = 0
    for i in range(63):
        k0 = path[i]
        ts = [F.from_integer(t)/a0 for t in [0, 1, 255]]
        us = [u + k0 for u in ts]
        c1 = a0 * us[0]
        real_k = (c0 - c1)
        real_ks.append(real_k.to_integer())
        a0 *= a
        c0 += real_k
        c0 *= a
    real_ks.append((b0 * (a ** 63) + c0).to_integer())
    print(bytes(real_ks).hex())
    io.sendlineafter(b&apos;Do you know my key?&apos;, bytes(real_ks).hex().encode())
    io.interactive()

for _ in tqdm(range(1000)):
    ret = try_solve()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;law and order&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;My friends were the FROST to implement sharing signatures for flag and ensured to always include me but somehow its still not working?&lt;/p&gt;
&lt;p&gt;Author: deuterium&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from py_ecc.secp256k1 import P, G as G_lib, N
from py_ecc.secp256k1.secp256k1 import multiply, add
import random
from secrets import randbelow
import os
from hashlib import sha256
import sys

CONTEXT = os.urandom(69)
NUM_PARTIES = 9
THRESHOLD = (2 * NUM_PARTIES) // 3 + 1
NUM_SIGNS = 100
FLAG = os.environ.get(&quot;FLAG&quot;, &quot;FLAG{I_AM_A_NINCOMPOOP}&quot;)

# IO
def send(msg):
    print(msg, flush=True)


def handle_error(msg):
    send(msg)
    sys.exit(1)


def receive_line():
    try:
        return sys.stdin.readline().strip()
    except BaseException:
        handle_error(&quot;Connection closed by client. Exiting.&quot;)


def input_int(range=P):
    try:
        x = int(receive_line())
        assert 0 &amp;lt;= x &amp;lt;= range - 1
        return x
    except BaseException:
        handle_error(&quot;Invalid input integer&quot;)


def input_point():
    try:
        x = input_int()
        y = input_int()
        assert not (x == 0 and y == 0)
        return Point(x, y)
    except BaseException:
        handle_error(&quot;Invalid input Point&quot;)


# Helper class
class Point:
    &quot;&quot;&quot;easy operator overloading&quot;&quot;&quot;

    def __init__(self, x, y):
        self.point = (x, y)

    def __add__(self, other):
        return Point(*add(self.point, other.point))

    def __mul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __rmul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __neg__(self):
        return Point(self.point[0], -self.point[1])

    def __eq__(self, other):
        return (self + (-other)).point[0] == 0

    def __repr__(self):
        return str(self.point)


G = Point(*G_lib)


def random_element(P):
    return randbelow(P - 1) + 1


def H(*args):
    return int.from_bytes(sha256(str(args).encode()).digest(), &quot;big&quot;)


def sum_pts(points):
    res = 0 * G
    for point in points:
        res = res + point
    return res


def sample_poly(t):
    return [random_element(N) for _ in range(t)]


def gen_proof(secret, i):
    k = random_element(P)
    R = k * G
    mu = k + secret * H(CONTEXT, i, secret * G, R)
    return R, mu % N


def verify_proof(C, R, mu, i):
    c = H(CONTEXT, i, C, R)
    return R == mu * G + (-c * C)


def gen_poly_comms(coeffs):
    return [i * G for i in coeffs]


def eval_poly(coeffs, x):
    res = 0
    for coeff in coeffs[::-1]:
        res = (res * x + coeff) % N
    return res


def gen_shares(n, coeffs):
    return {i: eval_poly(coeffs, i) for i in range(1, n + 1)}


def poly_eval_comms(comms, i):
    return sum_pts([comms[k] * pow(i, k, N) for k in range(THRESHOLD)])


def check_shares(comms, shares, i):
    return G * shares[i] == poly_eval_comms(comms, i)


def gen_nonsense():
    d, e = random_element(N), random_element(N)
    D, E = d * G, e * G
    return (d, e), (D, E)


def lamb(i, S):
    num, den = 1, 1
    for j in S:
        if j == i:
            continue
        num *= j
        den *= (j - i)
    return (num * pow(den, -1, N)) % N


def main():
    &quot;&quot;&quot;https://eprint.iacr.org/2020/852.pdf&quot;&quot;&quot;
    send(&quot;=&quot; * 50)
    send(&quot;=== Law and Order! You should always include your friends to sign and you are mine &amp;lt;3 ===&quot;)
    send(&quot;=&quot; * 50)
    send( f&quot;We have {NUM_PARTIES - 1} parties here, and you will be party #{NUM_PARTIES}.&quot;)
    send(f&quot;Idk why is our group signing not working&quot;)
    send(&quot;\n--- Round 1: Commitment ---&quot;)

    # Keygen

    send(f&quot;context string {CONTEXT.hex()}&quot;)

    your_id = NUM_PARTIES

    all_coeffs = {}
    all_comms = {}
    for i in range(1, NUM_PARTIES):
        # 1.1
        coeffs = sample_poly(THRESHOLD)
        # 1.2
        zero_proof = gen_proof(coeffs[0], i)
        # 1.3
        comms = gen_poly_comms(coeffs)
        # 1.5
        if not verify_proof(comms[0], zero_proof[0], zero_proof[1], i):
            handle_error(f&quot;[-] Party {i} secret PoK invalid&quot;)
        all_coeffs[i] = coeffs
        all_comms[i] = comms
        send(f&quot;[+] Commitments from party {i}:&quot;)
        for k, C_ik in enumerate(comms):
            send(f&quot;  C_{i},{k} = {C_ik}&quot;)

    send(&quot;\n[?] Now, provide the commitments (points) for your coefficients.&quot;)
    your_comms = [input_point() for _ in range(THRESHOLD)]
    send(&quot;\n[?] Finally, provide your proof-of-knowledge for your secret share (c_i,0).&quot;)
    send(&quot;[&amp;gt;] Send Point R:&quot;)
    your_zero_proof_R = input_point()
    send(&quot;[&amp;gt;] Send mu:&quot;)
    your_zero_proof_mu = input_int()
    your_zero_proof = (your_zero_proof_R, your_zero_proof_mu)

    if not verify_proof(your_comms[0], your_zero_proof[0], your_zero_proof[1], your_id):
        handle_error(f&quot;[-] party {your_id} secret PoK invalid&quot;)
    all_comms[your_id] = your_comms
    send(&quot;[+] Your commitments and proof have been accepted.&quot;)
    send(&quot;\n--- Round 2: Share Distribution ---&quot;)

    send(f&quot;[?] Please provide your shares for the other {NUM_PARTIES} parties.&quot;)
    # 2.1
    your_shares = {}
    for i in range(1, NUM_PARTIES + 1):
        send(f&quot;[&amp;gt;] Send share for party {i}:&quot;)
        your_shares[i] = input_int(N)

    # 2.2
    for i in range(1, NUM_PARTIES + 1):
        if not check_shares(your_comms, your_shares, i):
            handle_error(f&quot;[-] party {your_id} shares for party {i} invalid&quot;)
    send(&quot;[+] Your shares have been verified&quot;)

    all_shares = {}
    for l in range(1, NUM_PARTIES):
        shares_l = gen_shares(NUM_PARTIES, all_coeffs[l])
        for i in range(1, NUM_PARTIES + 1):
            if not check_shares(all_comms[l], shares_l, i):
                handle_error(f&quot;[-] party {l} shares for party {i} invalid&quot;)
        all_shares[l] = shares_l
        send(f&quot;[+] Share for you from party {l}: {shares_l[your_id]}&quot;)
    all_shares[your_id] = your_shares

    # 2.3
    signing_shares = {}
    for i in range(1, NUM_PARTIES + 1):
        signing_shares[i] = 0
        for j in range(1, NUM_PARTIES + 1):
            signing_shares[i] += all_shares[j][i]

    # 2.4
    group_public_key = sum_pts([all_comms[i][0] for i in range(1, NUM_PARTIES + 1)])
    send(f&quot;\n[+] Group Public Key: {group_public_key}&quot;)

    send(&quot;[?] Provide your public verification share `Y_i`.&quot;)
    your_public_share = input_point()
    if your_public_share != sum_pts([poly_eval_comms(all_comms[j], your_id) for j in range(1, NUM_PARTIES + 1)]):
        handle_error(f&quot;[-] party {your_id} public share invalid&quot;)

    public_shares = {i: v * G for i, v in signing_shares.items()}
    public_shares[your_id] = your_public_share
    send(&quot;[+] Public verification shares have been computed.&quot;)

    send(f&quot;\n--- Phase 3: Presign and Sign ({NUM_SIGNS} rounds) ---&quot;)
    for _ in range(NUM_SIGNS):
        # presign
        send(&quot;[?] Provide your nonces (D_i, E_i) for this round.&quot;)
        your_D = input_point()
        your_E = input_point()
        your_nonsense = (your_D, your_E)

        nonsense_sec, nonsense = {}, {}
        for i in range(1, NUM_PARTIES + 1):
            # 1.a, 1.b &amp;amp; 1.c
            (d, e), (D, E) = gen_nonsense()
            nonsense_sec[i] = (d, e)
            nonsense[i] = (D, E)
        nonsense[your_id] = your_nonsense

        # Sign

        # S is set of alpha, alpha is in t to n right?
        S = {random.randint(THRESHOLD, NUM_PARTIES) for _ in range(NUM_PARTIES - THRESHOLD)}
        S.add(your_id)  # should always include you &amp;lt;3
        send(f&quot;[+] Set of signers for this round: {S}&quot;)

        m = &quot;GIVE ME THE FLAG PLEASE&quot;
        combined_nonsense = {}

        group_nonsense = 0 * G
        nonsense = {i: nonsense[i] for i in S}
        # 2
        nonsense_ordered = sorted([(i, Di, Ei) for i, (Di, Ei) in nonsense.items()])
        # 4
        rhos = {i: H(i, m, nonsense_ordered) for i in S}
        for i, (D, E) in nonsense.items():
            send(f&quot;[+] Party {i} nonces: D={D}, E={E}&quot;)
            D, E = nonsense[i]
            nonsense_i = D + rhos[i] * E
            group_nonsense = group_nonsense + nonsense_i
            combined_nonsense[i] = nonsense_i

        group_challenge = H(group_nonsense, group_public_key, m)
        send(f&quot;[+] Group challenge `c`: {group_challenge}&quot;)
        send(&quot;[?] Provide your signature share `z_i`.&quot;)

        your_zi = input_int(N)

        # 7.b
        if your_zi * G != combined_nonsense[your_id] + \
                public_shares[your_id] * group_challenge * lamb(your_id, S):
            handle_error(f&quot;[-] party {your_id} signature shares invalid&quot;)

        final_signing_shares = {your_id: your_zi}
        for i in S - {your_id}:
            si = signing_shares[i]
            di, ei = nonsense_sec[i]
            # 5
            zi = di + (ei * rhos[i]) + si * group_challenge * lamb(i, S)
            Ri, Yi = combined_nonsense[i], public_shares[i]
            if Yi != sum_pts([poly_eval_comms(all_comms[j], i) for j in range(1, NUM_PARTIES + 1)]):
                handle_error(f&quot;[-] party {i} public share invalid&quot;)
            if zi * G != Ri + Yi * group_challenge * lamb(i, S):
                handle_error(f&quot;[-] party {i} signature share invalid&quot;)
            final_signing_shares[i] = zi

        # 7.c
        z = sum(final_signing_shares.values()) % N

        if z * G == group_nonsense + group_public_key * group_challenge:
            send(&quot;[+] Signature verification successful&quot;)
            send(f&quot;[+] Here is your flag: {FLAG}&quot;)
            sys.exit(0)
    handle_error(&quot;[-] We are out of signing ink&quot;)


if __name__ == &quot;__main__&quot;:
    try:
        main()
    except Exception as e:
        handle_error(&quot;[-] An error occured: {e}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;We have created many variants of this challenge. There&apos;re 2 major modifications:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Replace point equality check with &lt;code&gt;(self + (-other)).point == (0, 0)&lt;/code&gt;. It&apos;ll block points like &lt;code&gt;(0, y)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No input of &lt;code&gt;your_public_share&lt;/code&gt;, directly compute it from &lt;code&gt;all_comms&lt;/code&gt;. But we need either &lt;code&gt;NUM_PARTIES%3!=1&lt;/code&gt; or &lt;code&gt;NUM_PARTIES=15&lt;/code&gt; to solve it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Although we&apos;ve decided to use the easiest version, the handout still contains the second modification and &lt;code&gt;NUM_PARTIES&lt;/code&gt; is still 9.
I&apos;m very sorry for that.&lt;/p&gt;
&lt;p&gt;The bug is both &lt;code&gt;py_ecc&lt;/code&gt; and &lt;code&gt;Point&lt;/code&gt; didn&apos;t check if the point is on the curve. Hence we can send invalid points and try to pass all assertions.&lt;/p&gt;
&lt;p&gt;Let&apos;s first take a look at the point operations in &lt;code&gt;py_ecc&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;Jacobian coordinates&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;def from_jacobian(p: &quot;PlainPoint3D&quot;) -&amp;gt; &quot;PlainPoint2D&quot;:
    &quot;&quot;&quot;
    Convert a Jacobian point back to its corresponding 2D point representation.

    :param p: the point to convert
    :type p: PlainPoint3D

    :return: the 2D point representation
    :rtype: PlainPoint2D
    &quot;&quot;&quot;
    z = inv(p[2], P)
    return cast(&quot;PlainPoint2D&quot;, ((p[0] * z**2) % P, (p[1] * z**3) % P))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;py_ecc&lt;/code&gt; first converts the point to Jacobian coordinates and then do the calculation.
The relation of Jacobian coordinates and 2D coordinates is $(x, y, z)\sim(\frac{x}{z^2}, \frac{y}{z^3})$.&lt;/p&gt;
&lt;h4&gt;Double and add&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;def jacobian_add(p: &quot;PlainPoint3D&quot;, q: &quot;PlainPoint3D&quot;) -&amp;gt; &quot;PlainPoint3D&quot;:
    &quot;&quot;&quot;
    Add two points in Jacobian coordinates and return the result.

    :param p: the first point to add
    :type p: PlainPoint3D
    :param q: the second point to add
    :type q: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    if not p[1]:
        return q
    if not q[1]:
        return p
    U1 = (p[0] * q[2] ** 2) % P
    U2 = (q[0] * p[2] ** 2) % P
    S1 = (p[1] * q[2] ** 3) % P
    S2 = (q[1] * p[2] ** 3) % P
    if U1 == U2:
        if S1 != S2:
            return cast(&quot;PlainPoint3D&quot;, (0, 0, 1))
        return jacobian_double(p)
    H = U2 - U1
    R = S2 - S1
    H2 = (H * H) % P
    H3 = (H * H2) % P
    U1H2 = (U1 * H2) % P
    nx = (R**2 - H3 - 2 * U1H2) % P
    ny = (R * (U1H2 - nx) - S1 * H3) % P
    nz = (H * p[2] * q[2]) % P
    return cast(&quot;PlainPoint3D&quot;, (nx, ny, nz))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Addition doesn&apos;t use &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; at all, so it is actually adding on the unique curve &lt;code&gt;y^2 = x^3 + Ax + B&lt;/code&gt; determined by the input points.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def jacobian_double(p: &quot;PlainPoint3D&quot;) -&amp;gt; &quot;PlainPoint3D&quot;:
    &quot;&quot;&quot;
    Double a point in Jacobian coordinates and return the result.

    :param p: the point to double
    :type p: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    if not p[1]:
        return cast(&quot;PlainPoint3D&quot;, (0, 0, 0))
    ysq = (p[1] ** 2) % P
    S = (4 * p[0] * ysq) % P
    M = (3 * p[0] ** 2 + A * p[2] ** 4) % P
    nx = (M**2 - 2 * S) % P
    ny = (M * (S - nx) - 8 * ysq**2) % P
    nz = (2 * p[1] * p[2]) % P
    return cast(&quot;PlainPoint3D&quot;, (nx, ny, nz))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The doubling algorithm uses &lt;code&gt;A=0&lt;/code&gt; to compute. Similarly, it is doubling on the unique curve &lt;code&gt;y^2 = x^3 + B&lt;/code&gt; determined by the input point.&lt;/p&gt;
&lt;h4&gt;Infinite point&lt;/h4&gt;
&lt;p&gt;In &lt;code&gt;py_ecc&lt;/code&gt;, the infinite point is encoded as &lt;code&gt;(0, 0)&lt;/code&gt;. This isn&apos;t a problem if the point check is working correctly.
As we can see, if the y-axis is zero, the point is considered infinite in double and add.
It is very helpful because we can create infinite points more easily.&lt;/p&gt;
&lt;p&gt;Also, if the x-axis is same in addition, the result is also infinite.&lt;/p&gt;
&lt;h4&gt;Low order points attack&lt;/h4&gt;
&lt;p&gt;Even though we can create invalid points, it is still impossible to get the shared secret or control &lt;code&gt;group_challenge&lt;/code&gt;.
The only way to pass it is to generate an invalid &lt;code&gt;group_public_key&lt;/code&gt; whose order is sufficiently low.
Similarly, to pass &lt;code&gt;7.b&lt;/code&gt;, we want &lt;code&gt;your_public_share&lt;/code&gt; to be a low order point.
To achieve this, we must pass all the checks.&lt;/p&gt;
&lt;p&gt;The only way to control &lt;code&gt;group_public_key&lt;/code&gt; is sending bad &lt;code&gt;your_comms[0]&lt;/code&gt;.
But there&apos;s a commitment check which is hard for points not on the curve.
So we need &lt;code&gt;your_comms[0]&lt;/code&gt; to be a low order point to generate the commitment at infinite point.&lt;/p&gt;
&lt;p&gt;The next problem is passing &lt;code&gt;check_shares(your_comms, your_shares, i)&lt;/code&gt; for all parties.
It is the hardest part because now &lt;code&gt;your_comms[0]&lt;/code&gt; is not on the curve.
There&apos;re many feasible constructions.
The intended solution is to find the bug of &lt;code&gt;(self + (-other)).point[0] == 0&lt;/code&gt;, which allows &lt;code&gt;(0, y)==(0, 0)&lt;/code&gt;.
Also &lt;code&gt;(0, y)&lt;/code&gt; is a low order point, so everything is fine.
My idea is to use low order points and Fermat&apos;s little theorem. Details are in the harder version.[^eq]&lt;/p&gt;
&lt;p&gt;[^eq]: I&apos;ve tried to replace it with &lt;code&gt;self.point == other.point&lt;/code&gt;, but it&apos;ll be unsolvable because we still need &lt;code&gt;your_public_share&lt;/code&gt; not on the standard curve.&lt;/p&gt;
&lt;p&gt;The last problem is &lt;code&gt;your_public_share&lt;/code&gt;. Since it&apos;s from input, the check in &lt;code&gt;2.4&lt;/code&gt; is easily achievable.
We can fix the x-axis to be the same as &lt;code&gt;sum_pts([poly_eval_comms(all_comms[j], your_id) for j in range(1, NUM_PARTIES + 1)])&lt;/code&gt; and use the y-axis to find a low order point.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from sage.rings.generic import ProductTree
from pwn import *
from hashlib import sha256
from ast import literal_eval
from tqdm import tqdm
from py_ecc.secp256k1 import P, G as G_lib, N
from py_ecc.secp256k1.secp256k1 import multiply, add

NUM_PARTIES = 9
THRESHOLD = 7
p = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f
n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141

R = PolynomialRing(GF(p), [&apos;x&apos;, &apos;y&apos;])
xbar, ybar = R.gens()

def send_point(p):
    io.sendline(str(p.point[0]).encode())
    io.sendline(str(p.point[1]).encode())

def send_int(n):
    io.sendline(str(n).encode())

def jacobian_double(p):
    &quot;&quot;&quot;
    Double a point in Jacobian coordinates and return the result.

    :param p: the point to double
    :type p: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    # if not p[1]:
    #     return (0, 0, 0)
    ysq = (p[1] ** 2)
    S = (4 * p[0] * ysq)
    A = 0
    M = (3 * p[0] ** 2 + A * p[2] ** 4)
    nx = (M**2 - 2 * S)
    ny = (M * (S - nx) - 8 * ysq**2)
    nz = (2 * p[1] * p[2])
    return (nx, ny, nz)

def jacobian_add(p, q, return_H=False):
    &quot;&quot;&quot;
    Add two points in Jacobian coordinates and return the result.

    :param p: the first point to add
    :type p: PlainPoint3D
    :param q: the second point to add
    :type q: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    # if not p[1]:
    #     return q
    # if not q[1]:
    #     return p
    U1 = (p[0] * q[2] ** 2)
    U2 = (q[0] * p[2] ** 2)
    S1 = (p[1] * q[2] ** 3)
    S2 = (q[1] * p[2] ** 3)
    # if U1 == U2:
    #     if S1 != S2:
    #         return (0, 0, 1)
    #     return jacobian_double(p)
    H = U2 - U1
    R = S2 - S1
    H2 = (H * H)
    H3 = (H * H2)
    U1H2 = (U1 * H2)
    nx = (R**2 - H3 - 2 * U1H2)
    ny = (R * (U1H2 - nx) - S1 * H3)
    nz = (H * p[2] * q[2])
    if return_H:
        return (nx, ny, nz), H
    return (nx, ny, nz)

class Point:
    &quot;&quot;&quot;easy operator overloading&quot;&quot;&quot;
    def __init__(self, x, y):
        self.point = (x, y)

    def __add__(self, other):
        return Point(*add(self.point, other.point))

    def __mul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __rmul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __neg__(self):
        return Point(self.point[0], -self.point[1])

    def __eq__(self, other):
        return (self + (-other)).point == (0, 0)

    def to_jacobian(self):
        return (self.point[0], self.point[1], 1)

    def __repr__(self):
        return str(self.point)

G = Point(*G_lib)

def H(*args):
    return int.from_bytes(sha256(str(args).encode()).digest(), &quot;big&quot;)

def verify_proof(C, R, mu, i):
    c = H(CONTEXT, i, C, R)
    return R == mu * G + (-c * C)

def sum_pts(points):
    res = 0 * G
    for point in points:
        res = res + point
    return res

def poly_eval_comms(comms, i):
    return sum_pts([comms[k] * pow(i, k, N) for k in range(THRESHOLD)])

def check_shares(comms, shares, i):
    return G * shares[i] == poly_eval_comms(comms, i)

def start():
    global CONTEXT, io
    io = process([&quot;python3&quot;, &quot;chall.py&quot;])
    io.recvuntil(b&quot;context string&quot;)

    CONTEXT = bytes.fromhex(io.recvline().strip().decode())
    all_comms = {}

    for i in range(1, 9):
        io.recvuntil(b&quot;[+] Commitments from party &quot;)
        io.recvline()
        comm = []
        for j in range(THRESHOLD):
            c_j = literal_eval(io.recvline().strip().decode().split(&quot;=&quot;)[-1].strip())
            comm.append(Point(*c_j))
        all_comms[i] = comm

    pt0 = sum([all_comms[i][0] for i in range(1, NUM_PARTIES)], Point(0, 0))

    P0 = (xbar, ybar, 1)
    PA = jacobian_add(P0, pt0.to_jacobian())
    PB = jacobian_add(jacobian_double(P0), P0)

    poly1 = PA[1]
    poly2 = 4 * ybar**2 - 3 * xbar**3

    p3 = poly1.change_ring(ZZ).resultant(poly2.change_ring(ZZ), ybar.change_ring(ZZ)).change_ring(GF(p)).univariate_polynomial()

    def find():
        for x0 in p3.roots(multiplicities=False):
            if x0 == 0:
                continue
            p1 = PA[1](x=x0).univariate_polynomial()
            for y0 in p1.roots(multiplicities=False):
                if poly2(x=x0, y=y0) != 0:
                    continue
                your_comm0 = Point(int(x0), int(y0))
                if (pt0 + your_comm0).point[1] != 0:
                    continue
                return int(x0), int(y0)

    res = find()
    if res is None:
        io.close()
        return
    x0, y0 = res
    print(f&quot;Found x0: {x0}, y0: {y0}&quot;)
    assert poly2(x=x0, y=y0)==0

    your_comm0 = Point(int(x0), int(y0))

    assert (pt0 + your_comm0).point[1] == 0, (pt0 + your_comm0)
    assert (3 * your_comm0).point == (0, 0), (3 * your_comm0)

    def find_mu():
        for mu in range(1, 10000):
            R0 = mu * G
            if verify_proof(your_comm0, R0, mu, NUM_PARTIES):
                print(f&quot;Found mu: {mu}&quot;)
                return R0, mu

    your_zero_proof = find_mu()

    def find_7():
        Q7 = (xbar, ybar, 1)
        _, H7 = jacobian_add(Q7, jacobian_double(jacobian_add(Q7, jacobian_double(Q7))), return_H=True)
        T = jacobian_add(Q7, jacobian_double(Q7))
        H7 = H7//(ybar**8)
        Q0 = jacobian_add(your_comm0.to_jacobian(), Q7)

        while True:
            r = randint(1, p)
            R0 = r * G
            poly2 = R0.point[0] * Q0[2]**2 - Q0[0]
            poly1 = H7
            M = poly1.sylvester_matrix(poly2, ybar)
            R0 = PolynomialRing(GF(P), &apos;x&apos;)
            M = matrix(R0, M)
            p3 = M.determinant()
            for x0 in p3.roots(multiplicities=False):
                if x0 == 0:
                    continue
                p1 = poly2(x=x0).univariate_polynomial()
                for y0 in p1.roots(multiplicities=False):
                    if poly1(x=x0, y=y0) != 0:
                        continue
                    your_comm6 = Point(int(x0), int(y0))
                    if (your_comm0+your_comm6)==r*G:
                        return r, your_comm6

    def find_mid(your_comm6):
        while True:
            r7 = randint(1, p)
            r1 = randint(1, p)
            R7 = r7 * G
            R1 = r1 * G

            S0 = (R7.point[0], ybar, 1)
            S1 = jacobian_add(S0, your_comm6.to_jacobian())
            poly = S1[0] - R1.point[0] * S1[2]**2
            poly = poly.univariate_polynomial()
            for y0 in poly.roots(multiplicities=False):
                if y0 == 0:
                    continue
                mid = Point(R7.point[0], int(y0))
                if  R1 == (mid + your_comm6) and R7 == mid:
                    return r1, r7, mid

    def try_find_3(your_comm0, mid):
        R2 = PolynomialRing(GF(P), [&apos;r0&apos;, &apos;r1&apos;])
        r0, r1 = R2.gens()

        M2 = (12 * r0**2, 36 * r0**3, 1)
        M4 = (12 * r1**2, -36 * r1**3, 1)

        mid1 = jacobian_add(your_comm0.to_jacobian(), M2)
        mid2 = jacobian_add(mid.to_jacobian(), M4)

        rpoly1 = mid1[0] * mid2[2]**2 - mid2[0] * mid1[2]**2
        rpoly2 = mid1[1] * mid2[2]**3 - mid2[1] * mid1[2]**3

        M = rpoly1.sylvester_matrix(rpoly2, r1)
        R0 = PolynomialRing(GF(P), &apos;r0&apos;)
        rbar = R0.gen()
        M = matrix(R0, M)

        det = M.determinant()
        print(f&quot;det degree: {det.degree()}&quot;)

        for r0_sol in det.roots(multiplicities=False):
            if r0_sol == 0:
                continue
            if rpoly1(r0=r0_sol) == 0:
                continue
            for r1_sol in rpoly1(r0=r0_sol).univariate_polynomial().roots(multiplicities=False):
                if rpoly2(r0=r0_sol, r1=r1_sol) != 0:
                    continue
                if r1_sol == 0:
                    continue
                your_comm2 = Point(int(12 * r0_sol**2), int(36 * r0_sol**3))
                your_comm4 = Point(int(12 * r1_sol**2), int(36 * r1_sol**3))
                aaa = your_comm0 + your_comm2 + your_comm4
                if aaa.point == mid.point:
                    print(f&quot;Found your_comm2: {your_comm2}, your_comm4: {your_comm4}&quot;)
                    return your_comm2, your_comm4

    def try_find_2():
        while True:
            r3, your_comm6 = find_7()
            target_point = sum_pts([poly_eval_comms(all_comms[j], NUM_PARTIES) for j in range(1, NUM_PARTIES)])
            target_point = target_point + (your_comm0+your_comm6)
            poly = 4 * ybar**2 - 3 * target_point.point[0] ** 3
            y_fake = poly.univariate_polynomial().roots(multiplicities=False)
            if len(y_fake) &amp;gt; 0:
                break
        
        while True:
            r1, r7, mid = find_mid(your_comm6)
            try:
                res = try_find_3(your_comm0, mid)
                if res is not None:
                    your_comm2, your_comm4 = res
                    return r1, r3, r7, your_comm2, your_comm4, your_comm6
            except Exception as e:
                print(f&quot;Error: {e}, retrying with new r1, r7&quot;)
                continue

    r1, r3, r7, your_comm2, your_comm4, your_comm6 = try_find_2()
    your_comms = [your_comm0, Point(1, 0), your_comm2, Point(1, 0), your_comm4, Point(1, 0), your_comm6]
    shares = [None, r1, r1, r3, r1, r1, r3, r7, r1, r3]

    for i in range(1, NUM_PARTIES + 1):
        print(i, check_shares(your_comms, shares, i))

    for your_comm in your_comms:
        send_point(your_comm)
    all_comms[NUM_PARTIES] = your_comms

    send_point(your_zero_proof[0])
    send_int(your_zero_proof[1])

    io.recvuntil(b&quot;[+] Your commitments and proof have been accepted.&quot;)

    for i in range(1, NUM_PARTIES + 1):
        send_int(shares[i])

    my_shares = {}
    for i in range(1, NUM_PARTIES):
        io.recvuntil(b&quot;[+] Share for you from party &quot;)
        my_shares[i] = int(io.recvline().strip().decode().split(&quot;:&quot;)[-1].strip())
    my_shares[NUM_PARTIES] = r3

    target_point = sum_pts([poly_eval_comms(all_comms[j], NUM_PARTIES) for j in range(1, NUM_PARTIES + 1)])

    poly = 4 * ybar**2 - 3 * target_point.point[0] ** 3

    y_fake = poly.univariate_polynomial().roots(multiplicities=False)[0]
    P_fake = Point(target_point.point[0], y_fake)

    assert P_fake == target_point
    group_public_key = pt0 + your_comm0

    send_point(P_fake)

    io.recvuntil(b&quot;[+] Public verification shares have been computed.&quot;)

    def gen_D_E():
        while True:
            r = randint(1, p)
            E = Point(12*r**2 % p, 36*r**3 % p)
            D = (1337, ybar, 1)
            nonce = jacobian_add(D, E.to_jacobian())
            poly = nonce[1]
            poly = poly.univariate_polynomial()
            for y0 in poly.roots(multiplicities=False):
                if y0 == 0:
                    continue
                D = Point(1337, int(y0))
                nonsense_ordered = [(NUM_PARTIES, D, E)]
                m = &quot;GIVE ME THE FLAG PLEASE&quot;
                rho = H(NUM_PARTIES, m, nonsense_ordered)
                if rho % 3 == 1:
                    group_nonsense = D + E
                    group_challenge = H(group_nonsense, group_public_key, m)
                    if group_challenge % 3 != 0:
                        continue
                    return D, E
    
    D, E = gen_D_E()
    send_point(D)
    send_point(E)

    io.recvuntil(b&quot;[+] Set of signers for this round: &quot;)
    signers = literal_eval(io.recvline().strip().decode())
    print(f&quot;Signers: {signers}&quot;)
    if len(signers) &amp;gt; 1:
        io.close()
        return

    io.recvuntil(b&quot;[+] Group challenge `c`: &quot;)
    c = int(io.recvline().strip().decode())
    print(f&quot;Challenge {c}, {c % 3}&quot;)
    send_int(0)

    io.interactive()

for _ in range(100):
    start()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Harder version&lt;/h3&gt;
&lt;p&gt;Here&apos;s the harder version of the challenge.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--- chall.py
+++ chall_clean5.py
@@ -7,8 +7,8 @@
 import sys

 CONTEXT = os.urandom(69)
-NUM_PARTIES = 9
-THRESHOLD = (2 * NUM_PARTIES) // 3 + 1
+NUM_PARTIES = 15
+THRESHOLD = 13
 NUM_SIGNS = 100
 FLAG = os.environ.get(&quot;FLAG&quot;, &quot;FLAG{I_AM_A_NINCOMPOOP}&quot;)

@@ -68,7 +68,7 @@
         return Point(self.point[0], -self.point[1])

     def __eq__(self, other):
-        return (self + (-other)).point[0] == 0
+        return (self + (-other)).point == (0, 0)

     def __repr__(self):
         return str(self.point)
@@ -229,12 +229,8 @@
     group_public_key = sum_pts([all_comms[i][0] for i in range(1, NUM_PARTIES + 1)])
     send(f&quot;\n[+] Group Public Key: {group_public_key}&quot;)

-    send(&quot;[?] Provide your public verification share `Y_i`.&quot;)
-    your_public_share = input_point()
-    if your_public_share != sum_pts([poly_eval_comms(all_comms[j], your_id) for j in range(1, NUM_PARTIES + 1)]):
-        handle_error(f&quot;[-] party {your_id} public share invalid&quot;)
-
     public_shares = {i: v * G for i, v in signing_shares.items()}
+    your_public_share = sum_pts([poly_eval_comms(all_comms[j], your_id) for j in range(1, NUM_PARTIES + 1)])
     public_shares[your_id] = your_public_share
     send(&quot;[+] Public verification shares have been computed.&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The solution is similar to the easier one except the construction of &lt;code&gt;your_comms&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For simplicity, denote $y_i$ as &lt;code&gt;your_comm[i]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Let&apos;s assume $y_2$ and $y_4$ are order $3$ points.
Hence $y_0 + i^2y_2 + i^4y_4$ has two values. If $3\nmid i$, it&apos;ll be $y_0+y_2+y_4$ and if $3\mid i$, it&apos;ll be $y_0$. It is intuitive that $y_0+y_2+y_4$ can go to almost any points.
Similarly, we can set $y_6$ and $y_{12}$ to be order $7$ points. For $3\mid i$, it&apos;ll be $y_0+y_6+y_{12}$, hence we can control the value of &lt;code&gt;your_public_share&lt;/code&gt;.
The final step is to make sure $y_0+y_2+y_4$ and $y_0+y_2+y_4+y_6+y_{12}$ can be verified. This is easy because $y_2$ and $y_4$ are still unused.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from sage.rings.generic import ProductTree
from pwn import *
from hashlib import sha256
from ast import literal_eval
from tqdm import tqdm
from py_ecc.secp256k1 import P, G as G_lib, N
from py_ecc.secp256k1.secp256k1 import multiply, add

NUM_PARTIES = 15
THRESHOLD = 13
p = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f
n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141

R = PolynomialRing(GF(p), [&apos;x&apos;, &apos;y&apos;])
xbar, ybar = R.gens()

def jacobian_double(p):
    &quot;&quot;&quot;
    Double a point in Jacobian coordinates and return the result.

    :param p: the point to double
    :type p: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    # if not p[1]:
    #     return (0, 0, 0)
    ysq = (p[1] ** 2)
    S = (4 * p[0] * ysq)
    A = 0
    M = (3 * p[0] ** 2 + A * p[2] ** 4)
    nx = (M**2 - 2 * S)
    ny = (M * (S - nx) - 8 * ysq**2)
    nz = (2 * p[1] * p[2])
    return (nx, ny, nz)

def send_point(p):
    io.sendline(str(p.point[0]).encode())
    io.sendline(str(p.point[1]).encode())

def send_int(n):
    io.sendline(str(n).encode())

def jacobian_add(p, q, return_H=False):
    &quot;&quot;&quot;
    Add two points in Jacobian coordinates and return the result.

    :param p: the first point to add
    :type p: PlainPoint3D
    :param q: the second point to add
    :type q: PlainPoint3D

    :return: the resulting Jacobian point
    :rtype: PlainPoint3D
    &quot;&quot;&quot;
    # if not p[1]:
    #     return q
    # if not q[1]:
    #     return p
    U1 = (p[0] * q[2] ** 2)
    U2 = (q[0] * p[2] ** 2)
    S1 = (p[1] * q[2] ** 3)
    S2 = (q[1] * p[2] ** 3)
    # if U1 == U2:
    #     if S1 != S2:
    #         return (0, 0, 1)
    #     return jacobian_double(p)
    H = U2 - U1
    R = S2 - S1
    H2 = (H * H)
    H3 = (H * H2)
    U1H2 = (U1 * H2)
    nx = (R**2 - H3 - 2 * U1H2)
    ny = (R * (U1H2 - nx) - S1 * H3)
    nz = (H * p[2] * q[2])
    if return_H:
        return (nx, ny, nz), H
    return (nx, ny, nz)

class Point:
    &quot;&quot;&quot;easy operator overloading&quot;&quot;&quot;

    def __init__(self, x, y):
        self.point = (x, y)

    def __add__(self, other):
        return Point(*add(self.point, other.point))

    def __mul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __rmul__(self, scalar):
        return Point(*multiply(self.point, scalar))

    def __neg__(self):
        return Point(self.point[0], -self.point[1])

    def __eq__(self, other):
        return (self + (-other)).point == (0, 0)

    def to_jacobian(self):
        return (self.point[0], self.point[1], 1)

    def __repr__(self):
        return str(self.point)

G = Point(*G_lib)

def H(*args):
    return int.from_bytes(sha256(str(args).encode()).digest(), &quot;big&quot;)

def verify_proof(C, R, mu, i):
    c = H(CONTEXT, i, C, R)
    return R == mu * G + (-c * C)

def sum_pts(points):
    res = 0 * G
    for point in points:
        res = res + point
    return res

def poly_eval_comms(comms, i):
    return sum_pts([comms[k] * pow(i, k, N) for k in range(THRESHOLD)])

def check_shares(comms, shares, i):
    return G * shares[i] == poly_eval_comms(comms, i)

def gen_7_point():
    while True:
        E = EllipticCurve(GF(p), [0, randint(1, p-1)])
        order = E.order()
        if order % 7 == 0:
            break
    return E.torsion_basis(7)

def start():
    global CONTEXT, io
    io = process([&quot;python3&quot;, &quot;chall_clean5.py&quot;])
    io.recvuntil(b&quot;context string&quot;)

    CONTEXT = bytes.fromhex(io.recvline().strip().decode())

    all_comms = {}

    for i in range(1, NUM_PARTIES):
        io.recvuntil(b&quot;[+] Commitments from party &quot;)
        io.recvline()
        comm = []
        for j in range(THRESHOLD):
            c_j = literal_eval(io.recvline().strip().decode().split(&quot;=&quot;)[-1].strip())
            comm.append(Point(*c_j))
        all_comms[i] = comm

    pt0 = sum([all_comms[i][0] for i in range(1, NUM_PARTIES)], Point(0, 0))

    P0 = (xbar, ybar, 1)

    PA = jacobian_add(P0, pt0.to_jacobian())
    PB = jacobian_add(jacobian_double(P0), P0)

    RZ = PolynomialRing(ZZ, [&apos;x&apos;, &apos;y&apos;])

    poly1 = PA[1]
    poly2 = 4 * ybar**2 - 3 * xbar**3

    p3 = poly1.change_ring(ZZ).resultant(poly2.change_ring(ZZ), ybar.change_ring(ZZ)).change_ring(GF(p)).univariate_polynomial()

    def find():
        for x0 in p3.roots(multiplicities=False):
            if x0 == 0:
                continue
            p1 = PA[1](x=x0).univariate_polynomial()
            for y0 in p1.roots(multiplicities=False):
                if poly2(x=x0, y=y0) != 0:
                    continue
                your_comm0 = Point(int(x0), int(y0))
                if (pt0 + your_comm0).point[1] != 0:
                    continue
                return int(x0), int(y0)

    res = find()
    if res is None:
        io.close()
        return
    x0, y0 = res
    print(f&quot;Found x0: {x0}, y0: {y0}&quot;)
    assert poly2(x=x0, y=y0)==0

    your_comm0 = Point(int(x0), int(y0))

    assert (pt0 + your_comm0).point[1] == 0, (pt0 + your_comm0)
    assert (3 * your_comm0).point == (0, 0), (3 * your_comm0)

    def find_mu():
        for mu in range(1, 10000):
            R0 = mu * G
            if verify_proof(your_comm0, R0, mu, NUM_PARTIES):
                print(f&quot;Found mu: {mu}&quot;)
                return R0, mu

    your_zero_proof = find_mu()

    your_public_share_pt0 = sum_pts([poly_eval_comms(all_comms[j], NUM_PARTIES) for j in range(1, NUM_PARTIES)])

    def find_7(your_comm0):
        R2 = PolynomialRing(GF(P), [&apos;r0&apos;, &apos;r1&apos;])
        r0, r1 = R2.gens()

        A7_, B7_ = gen_7_point()

        A7 = (A7_.x() * r0 ** 2, A7_.y() * r0 ** 3, 1)
        B7 = (B7_.x() * r1 ** 2, B7_.y() * r1 ** 3, 1)

        C = jacobian_add(your_comm0.to_jacobian(), A7)
        C = jacobian_add(C, B7)

        while True:
            r3 = randint(1, p)
            R = r3 * G
            R_fake = (R.point[0], ybar, 1)
            fake_share = jacobian_add(your_public_share_pt0.to_jacobian(), R_fake)
            poly0 = 4 * fake_share[1] ** 2 - 3 * fake_share[0] ** 3
            y_fake = poly0.univariate_polynomial().roots(multiplicities=False)
            if len(y_fake) == 0:
                continue
            y_fake = y_fake[-1]
            R_fake = (int(R.point[0]), int(y_fake))

            poly1 = R_fake[0] * C[2] ** 2 - C[0]
            poly2 = R_fake[1] * C[2] ** 3 - C[1]

            M = poly1.sylvester_matrix(poly2, r1)
            R0 = PolynomialRing(GF(P), &apos;r0&apos;)
            M = matrix(R0, M)
            det = M.determinant()
            print(f&quot;det degree: {det.degree()}&quot;)

            for r0_sol in det.roots(multiplicities=False):
                if r0_sol == 0:
                    continue
                if poly1(r0=r0_sol) == 0:
                    continue
                for r1_sol in poly1(r0=r0_sol).univariate_polynomial().roots(multiplicities=False):
                    if poly2(r0=r0_sol, r1=r1_sol) != 0:
                        continue
                    if r1_sol == 0:
                        continue
                    your_comm6 = Point(int(A7_.x() * r0_sol ** 2), int(A7_.y() * r0_sol ** 3))
                    your_comm12 = Point(int(B7_.x() * r1_sol ** 2), int(B7_.y() * r1_sol ** 3))
                    aaa = your_comm0 + your_comm6 + your_comm12
                    if aaa.point == R_fake:
                        return r3, your_comm6, your_comm12

    def find_mid(your_comm6, your_comm12):
        while True:
            r7 = randint(1, p)
            r1 = randint(1, p)
            R7 = r7 * G
            R1 = r1 * G

            S0 = (R7.point[0], ybar, 1)
            S1 = jacobian_add(S0, your_comm6.to_jacobian())
            S1 = jacobian_add(S1, your_comm12.to_jacobian())
            poly = S1[0] - R1.point[0] * S1[2]**2
            poly = poly.univariate_polynomial()
            for y0 in poly.roots(multiplicities=False):
                if y0 == 0:
                    continue
                mid = Point(R7.point[0], int(y0))
                if R1 == (mid + your_comm6 + your_comm12) and R7 == mid:
                    return r1, r7, mid

    def try_find_3(your_comm0, mid):
        R2 = PolynomialRing(GF(P), [&apos;r0&apos;, &apos;r1&apos;])
        r0, r1 = R2.gens()

        M2 = (12 * r0**2, 36 * r0**3, 1)
        M4 = (12 * r1**2, -36 * r1**3, 1)

        mid1 = jacobian_add(your_comm0.to_jacobian(), M2)
        mid2 = jacobian_add(mid.to_jacobian(), M4)

        rpoly1 = mid1[0] * mid2[2]**2 - mid2[0] * mid1[2]**2
        rpoly2 = mid1[1] * mid2[2]**3 - mid2[1] * mid1[2]**3

        M = rpoly1.sylvester_matrix(rpoly2, r1)
        R0 = PolynomialRing(GF(P), &apos;r0&apos;)
        rbar = R0.gen()
        M = matrix(R0, M)

        det = M.determinant()
        print(f&quot;det degree: {det.degree()}&quot;)

        for r0_sol in det.roots(multiplicities=False):
            if r0_sol == 0:
                continue
            if rpoly1(r0=r0_sol) == 0:
                continue
            for r1_sol in rpoly1(r0=r0_sol).univariate_polynomial().roots(multiplicities=False):
                if rpoly2(r0=r0_sol, r1=r1_sol) != 0:
                    continue
                if r1_sol == 0:
                    continue
                your_comm2 = Point(int(12 * r0_sol**2), int(36 * r0_sol**3))
                your_comm4 = Point(int(12 * r1_sol**2), int(36 * r1_sol**3))
                aaa = your_comm0 + your_comm2 + your_comm4
                if aaa.point == mid.point:
                    print(f&quot;Found your_comm2: {your_comm2}, your_comm4: {your_comm4}&quot;)
                    return your_comm2, your_comm4

    def try_find_2():
        r3, your_comm6, your_comm12 = find_7(your_comm0)
        print(f&quot;Found r3: {r3}, your_comm6: {your_comm6}, your_comm12: {your_comm12}&quot;)
        
        while True:
            r1, r7, mid = find_mid(your_comm6, your_comm12)
            try:
                res = try_find_3(your_comm0, mid)
                if res is not None:
                    your_comm2, your_comm4 = res
                    return r1, r3, r7, your_comm2, your_comm4, your_comm6, your_comm12
            except Exception as e:
                print(f&quot;Error: {e}, retrying with new r1, r7&quot;)
                continue

    r1, r3, r7, your_comm2, your_comm4, your_comm6, your_comm12 = try_find_2()
    your_comms = [your_comm0, Point(1, 0), your_comm2, Point(1, 0), your_comm4, Point(1, 0), your_comm6] + [Point(1, 0)] * 5 + [your_comm12]
    shares = [None, r1, r1, r3, r1, r1, r3, r7, r1, r3, r1, r1, r3, r1, r7, r3]

    for i in range(1, NUM_PARTIES + 1):
        print(i, check_shares(your_comms, shares, i))

    for your_comm in your_comms:
        send_point(your_comm)
    all_comms[NUM_PARTIES] = your_comms

    send_point(your_zero_proof[0])
    send_int(your_zero_proof[1])

    io.recvuntil(b&quot;[+] Your commitments and proof have been accepted.&quot;)

    for i in range(1, NUM_PARTIES + 1):
        send_int(shares[i])

    my_shares = {}
    for i in range(1, NUM_PARTIES):
        io.recvuntil(b&quot;[+] Share for you from party &quot;)
        my_shares[i] = int(io.recvline().strip().decode().split(&quot;:&quot;)[-1].strip())
    my_shares[NUM_PARTIES] = r3

    group_public_key = pt0 + your_comm0
    io.recvuntil(b&quot;[+] Public verification shares have been computed.&quot;)

    def gen_D_E():
        while True:
            r = randint(1, p)
            E = Point(12*r**2 % p, 36*r**3 % p)
            D = (1337, ybar, 1)
            nonce = jacobian_add(D, E.to_jacobian())
            poly = nonce[1]
            poly = poly.univariate_polynomial()
            for y0 in poly.roots(multiplicities=False):
                if y0 == 0:
                    continue
                D = Point(1337, int(y0))
                nonsense_ordered = [(NUM_PARTIES, D, E)]
                m = &quot;GIVE ME THE FLAG PLEASE&quot;
                rho = H(NUM_PARTIES, m, nonsense_ordered)
                if rho % 3 == 1:
                    group_nonsense = D + E
                    group_challenge = H(group_nonsense, group_public_key, m)
                    if group_challenge % 3 != 0:
                        continue
                    return D, E
    
    D, E = gen_D_E()

    send_point(D)
    send_point(E)

    io.recvuntil(b&quot;[+] Set of signers for this round: &quot;)
    signers = literal_eval(io.recvline().strip().decode())
    print(f&quot;Signers: {signers}&quot;)
    if len(signers) &amp;gt; 1:
        io.close()
        return

    io.recvuntil(b&quot;[+] Group challenge `c`: &quot;)
    c = int(io.recvline().strip().decode())
    print(f&quot;Challenge {c}, {c % 3}&quot;)
    send_int(0)

    io.interactive()

for _ in range(100):
    start()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Introduction to Elliptic Curve over Complex Fields</title><link>https://blog.sceleri.cc/posts/complex-elliptic-curve/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/complex-elliptic-curve/</guid><pubDate>Mon, 07 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;之前参加了 imaginaryCTF 的练习赛，解了 &lt;code&gt;Complex Curve Crypto&lt;/code&gt; 这道在复数域上的椭圆曲线题，很好玩。
做这道题的时候也学习了一下复数域上的椭圆曲线，所以会顺带介绍一下。&lt;/p&gt;
&lt;h2&gt;椭圆曲线与模形式&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Theorem.&amp;lt;/mark&amp;gt;
对所有椭圆曲线 $E$，都有 $E\cong\mathbb{C}/\Lambda$，其中 $\Lambda=\mathbb{Z}\omega_1+\mathbb{Z}\omega_2$ 是一个复数域上的格。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个定理证明很复杂，我自己也搞不太懂。
大概是 Weierstrass 函数同时满足了 $\wp(x+z_1)=\wp(x+z_2)=\wp(x)$ 和 $\wp&apos;(z)^2=4\wp(z)^3-g_2\wp(z)-g_3$，所以可以构造一个映射。&lt;/p&gt;
&lt;p&gt;这个定理说明我们可以把椭圆曲线上的离散对数问题变成一个格上的离散对数问题。
而格上的离散对数就是一个有理逼近问题，所以可以用 LLL 解决。&lt;/p&gt;
&lt;p&gt;接下来是 $j$-invariant 的定义。
这个东西最初是用来分类格的同构类的。
首先一个格在旋转下是同构的，所以我们可以把一个格简化为 $\Lambda=\mathbb{Z}+\mathbb{Z}\tau, \tau=\frac{\omega_2}{\omega_1}$。
但即便是这样，$\tau$ 也不能唯一确定格的同构类，例如 $\omega_1=2+\tau,\omega_2=1+\tau$ 也表示了同样的格。
实际上，一个格的全部同构类为 $\tau\sim\frac{a\tau+b}{c\tau+d}, |ad-bc|=1$，其中 $a,b,c,d\in\mathbb{Z}$。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
$j$-invariant 是唯一一个满足 $j(\tau)=j(\frac{a\tau+b}{c\tau+d}), ad-bc=1$ 且 $j(i)=1728, j(e^{2\pi i/3})=0$，定义在复数域上半平面的全纯函数。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;同样，这个的证明也很难，而且也牵扯到了模形式的一些东西。所以只做密码学的话可以跳过了，总之先相信（x&lt;/p&gt;
&lt;p&gt;对于所有的格，$j$-invariant 都唯一地确定了它的同构类。
基于前面的定理，我们可以把椭圆曲线的同构类和格的同构类联系起来，这自然给出了椭圆曲线的 $j$-invariant。[^1]&lt;/p&gt;
&lt;p&gt;[^1]: 这也是那个 1728 的由来。&lt;/p&gt;
&lt;p&gt;通过各种变换，我们可以把所有 $\tau$ 变换到 $|\tau|\geq 1,|\text{Re}(\tau)|&amp;lt;\frac{1}{2}$ 的区域上（图自己去 &lt;a href=&quot;https://en.wikipedia.org/wiki/J-invariant&quot;&gt;wikipedia&lt;/a&gt; 看）。&lt;/p&gt;
&lt;p&gt;然后，我们来看一下椭圆曲线在复数域上的 isogeny。首先，我们可以仿照有限域，利用 torsion point 来构造 isogeny。而 torsion point 在格上就变成了类似 $n$ 等分点的东西。
因此从格的角度来看，isogeny 是两个共享很多 $n$ 等分点的格之间的部分同态（或者说嵌入？）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;lt;mark&amp;gt;Prop.&amp;lt;/mark&amp;gt;
classical modular polynomial 的一组解是一个 isogeny 的对应格的 $j$-invariant，即 $j(n\tau),j(\tau)$ 是 classical modular polynomial 的一组解。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在椭圆曲线 isogeny 的 mitm 攻击中，我们也会用到 classical modular polynomial 来加速遍历。
这就是用到了它的解集恰好是 isogeny 的两个椭圆曲线的 $j$-invariant 这个性质。
对于一个 $\tau$，它的 $n$ 次 isogeny 有 $n+1$ 个，分别是 $n\tau$ 和 $\frac{\tau+k}{n}, k=0,1,\cdots,n-1$，
恰好是格的所有不同构的 $n$ 等分点。&lt;/p&gt;
&lt;p&gt;所以说，isogeny 在复数域上的性质是非常好的，后面这道题就是一个例子。&lt;/p&gt;
&lt;h2&gt;Complex Curve Crypto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
phi = classical_modular_polynomial(l:=3)
flag = ZZ.from_bytes(os.environ.get(&apos;FLAG&apos;, &apos;jctf{fakeflag}&apos;).encode())
j = jp = ComplexField(1337)(1337)
for d in flag.digits(l):
    roots = phi(X=j).univariate_polynomial().roots(multiplicities=False)
    roots.sort(key=jp.dist)
    del roots[0] # no turning back
    roots.sort(key=arg)
    j, jp = roots[d], j
print(j)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这道题通过 classical modular polynomial 实现了复数域上的 isogeny。
我们的目标是找到通往最终 $j$-invariant 的路径。&lt;/p&gt;
&lt;p&gt;我们先调用 pari 的库算出起点和终点的 $\tau$。但$j$-invariant 的逆不唯一，我们实际获取的那个是 normalized 的那一个解。&lt;/p&gt;
&lt;p&gt;根据前面的说明，我们知道起始值是 $j(\tau)=1337$，而每次 isogeny 都会将其变成 $j(\frac{\tau+k}{n}), k=0,1,\cdots,n-1$。[^2]
因此在最终 $\tau$ 会变成 $\frac{\tau+k}{3^n}, k\in\mathbb{Z}$。
可以看到，这个过程中 $\tau$ 的虚部会不断变小，所以最终的解一定无法轻易地 normalize 到 $|\tau|\geq 1,|\text{Re}(\tau)|&amp;lt;\frac{1}{2}$ 的区域上。&lt;/p&gt;
&lt;p&gt;[^2]: 这里有个小trick，我们需要先通过 $\text{PSL}(2,\mathbb{Z})$ 将 $\tau$ 变一下，使得 $j(3\tau)$ 是被删掉的那一个。这样就可以通过不走回头路的方式躲掉 $\tau\to3\tau$ 的情况。&lt;/p&gt;
&lt;p&gt;于是，问题转化为了给定 $\tau_1, \tau_2$，怎么找到 $k$ 使得 $j(\tau_2)=j(\frac{\tau_1+k}{3^n})$。
我们可以把这个写成 $\text{PSL}(2,\mathbb{Z})$ 的变换，即 $\frac{a\tau_2+b}{c\tau_2+d}=\frac{\tau_1+k}{3^n}$。&lt;/p&gt;
&lt;p&gt;这个式子的实部因为 $k$ 的存在，是非常难解的，但好消息是虚部是不变的，而 $a,b,c,d$ 的值大概是 $3^n$ 的量级，所以如果精度足够高的话，我们可以用 LLL 来找小整数解。
我们先利用 $ad-bc=1$ 化简一下虚部。设$\tau_2=x+yi$，&lt;/p&gt;
&lt;p&gt;$$
\begin{align*}
&amp;amp;\text{Im}\left(\frac{a\tau_2+b}{c\tau_2+d}\right) \
&amp;amp;=\text{Im}\left(\frac{ax+b+ayi}{cx+d+cyi}\right) \
&amp;amp;=\text{Im}\left(\frac{(ax+b+ayi)(cx+d-cyi)}{(cx+d)^2+(cy)^2}\right) \
&amp;amp;=\frac{(cx+d)ay-(ax+b)cy}{(cx+d)^2+(cy)^2} \
&amp;amp;=\frac{y}{c^2(x^2+y^2)+2cdx+d^2} \
\end{align*}
$$&lt;/p&gt;
&lt;p&gt;在一通消元后，我们只需要把 $c^2, cd, d^2$ 当做未知数，构造一个矩阵，然后用 LLL 求解。
这个 LLL 还是有一些不稳定，所以需要用 $c^2, cd, d^2$ 的关系稍微调一下。&lt;/p&gt;
&lt;p&gt;在解出 $k$ 之后，我们可以直接算出每一步对应的 $j$-invariant，然后稍微验证一下就可以知道是第几个了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from mpmath import mp

def babai_cvp(B, t, perform_reduction=True):
    if perform_reduction:
        B = B.LLL(delta=0.75)

    G = B.gram_schmidt()[0]
    b = t
    for i in reversed(range(B.nrows())):
        c = ((b * G[i]) / (G[i] * G[i])).round()
        b -= c * B[i]

    return b

l = 3
phi = classical_modular_polynomial(l)
flag = ZZ.from_bytes(os.environ.get(&apos;FLAG&apos;, &apos;jctf{ghrf65fakefd2lag}&apos;).encode())
# flag = ZZ.from_bytes(b&apos;jctf{fakef212113lag}&apos;)
j = jp = ComplexField(1337)(1337)
print(len(flag.digits(l)), flag.digits(l))
ddd = [2, 1, 2, 2, 1, 2, 2, 0, 1, 1, 1, 2, 0, 0, 0, 1, 0, 2, 0, 2, 1, 0, 1, 0, 1, 2, 2, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 2, 1, 0, 1, 0, 0, 2, 1, 0, 2, 1, 2, 0, 1, 0, 2, 1, 1, 1, 0, 1, 0, 2, 2, 1, 0, 2, 0, 0, 1, 2, 0, 2, 2, 2, 2, 0, 0, 1, 0, 1, 2, 1, 1, 2, 0, 0, 2, 2, 1, 2, 0, 1, 2, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 2, 1, 2, 2, 0, 0, 1, 0, 1, 2, 0, 0, 2, 0, 1, 1, 1, 2, 1, 2, 1, 1, 2, 0, 1, 2, 1, 2, 2, 0, 1]
for d in ddd:
    roots = phi(X=j).univariate_polynomial().roots(multiplicities=False)
    
    roots.sort(key=jp.dist)
    del roots[0] # no turning back
    roots.sort(key=arg)
    j, jp = roots[d], j
print(j)
target_j = j

mp.dps = 8000
pari(&quot;\p 8000&quot;)
def get_tau(j_val):
    &quot;&quot;&quot;
    solve j(tau) = j_val
    &quot;&quot;&quot;
    j_val = CC(j_val)
    output = pari(f&quot;ellinit([{str(j_val)}]).omega&quot;)
    w1, w2 = output
    tau = w1 / w2
    return CC(tau)

CC = ComplexField(8000)
# target_j = CC(target_j)
target_j = CC([&quot;-6380.29156755903034687704660000282270448709570700984709657263854520138341881550084848649839410922876936678108641559731906139407589458824525200739437427675429842545943862350406898342718600643307574207936681432539822432021043321858999100455988010088297166788530685045590301521392833041157761230553808660966639987227996254364651040683587985017755013287627806460764447918220597797227073500295101888328945378&quot;, &quot;-40214.6672669145274363733990190234269396915010860735171342583778810557492152506583335099397606096949655915384682940536670398063862158837139959936293817852537495478495004482851179789975853315750442943613549504080182872285152393975211173312274904654221951964258004673123691758298620284682558712820558193891787303533093197420998108945366704043133890680849252774739771188873183182185315971489373696493547256&quot;]) # i=136
t = get_tau(CC(1337))
t = (t - 4) / (t - 3)

R = PolynomialRing(RR, &apos;x&apos;)
xbar = R.gen()

def try_get_transform(t1, t2):
        &quot;&quot;&quot;
        t2 = (a*t1 + b) / (c*t1 + d), a*d - b*c = 1
        &quot;&quot;&quot;
        x0 = t1.real()
        x1 = t2.real()
        y0 = t1.imag()
        y1 = t2.imag()
        u = y0 / y1
        v1 = x0 ** 2 + y0 ** 2
        v2 = 2 * x0
        S1 = 2**3800
        S2 = 2**3000
        M = matrix(ZZ, [[S1, -S2, 0, 0], [(v1*S1).round(), 0, -S2, 0], [(v2*S1).round(), 0, 0, -S2]])
        target = vector(ZZ, [(u*S1).round(), 0, 0, 0])
        M = M.LLL()
        short_vectors = []
        v=M[0]
        v = [v[1] // S2, v[2] // S2, v[3] // S2]
        sol = babai_cvp(M, target, perform_reduction=False)
        
        dd = sol[1] // S2
        cc = sol[2] // S2
        cd = sol[3] // S2
        print(M[0][0].bit_length(), abs(sol[0]).bit_length(), sol[1].bit_length(), sol[2].bit_length(), sol[3].bit_length())
        print(cc, cd, dd)
        print(v)

        f = (dd+v[0]*xbar)*(cc+v[1]*xbar) - (cd+v[2]*xbar)**2
        rrr = f.roots(multiplicities=False)
        find = False
        print(&quot;rrr&quot;, rrr)
        for r in rrr[1:]:
            rr = r.round()
            cc2 = cc + rr * v[1]
            dd2 = dd + rr * v[0]
            cd2 = cd + rr * v[2]
            if cc2.is_square() and dd2.is_square() and gcd(cc2, dd2) == 1:
                print(&quot;Found a solution:&quot;, cc2, cd2, dd2)
                find = True
                cc, cd, dd = cc2, cd2, dd2
                break
        if not find:
            return None

        # print(cc * v1 + cd * v2 + dd - u)
        if not (cc.is_square() and dd.is_square()):
            return None
        c = sqrt(cc)
        d = sqrt(dd)
        if cd &amp;lt; 0:
            d = -d
        assert c * d == cd
        print(f&quot;c = {c}, d = {d}&quot;)
        print(d**2+c**2 * v1 + cd * v2 - u)
        # c**2 * (x0**2+y0**2) + 2cd * x0 + d**2 = u
        return c, d

tau1 = t
tau2 = get_tau(target_j)

print()
print(target_j)
print(elliptic_j((t+1)/3))

for i in range(10, 180):
    print(&quot;checking i =&quot;, i)
    k = 3**i
    ret = try_get_transform(tau2, tau1/k)
    if ret is None:
        continue
    c, d = ret
    print(gcd(c, d))

    _, a, b = xgcd(d, c)
    b = -b

    assert a * d - b * c == 1

    tau2_transformed = (a * tau2 + b) / (c * tau2 + d)
    u = tau2_transformed.real_part().floor()
    a -= u * c
    b -= u * d
    tau2_transformed = (a * tau2 + b) / (c * tau2 + d)
    print(tau2_transformed)
    
    u = (tau2_transformed*k).real()
    v = tau1.real()
    if abs(u.frac() - v.frac()) &amp;gt; abs(u.frac() - (1 - v.frac())):
        raise
        tau2_transformed = 1-tau2_transformed.conjugate()

    print((tau2_transformed*k - tau1))
    print((tau2_transformed*k - tau1).real().round(), k)
    u0 = (tau2_transformed*k - tau1).real().round()
    break

def get_j(tau):
    while True:
        tau -= tau.real_part().round()
        if tau.norm() &amp;lt;= 1:
            tau = -1 / tau
        else:
            break
    return elliptic_j(tau)

print(get_j((tau1+u0) / k))
digits = []
test_j = test_jp = ComplexField(1337)(1337)

def walk(j, jp, d):
    roots = phi(X=j).univariate_polynomial().roots(multiplicities=False)
    roots.sort(key=jp.dist)
    del roots[0] # no turning back
    roots.sort(key=arg)
    return roots[d], j

for i in range(400):
    if k &amp;lt;= 3**i:
        break

    jp = get_j((tau1+u0) / 3**(i-1))
    j0 = get_j((tau1+u0) / 3**i)
    j1 = get_j((tau1+u0) / 3**(i+1))

    roots = phi(X=ComplexField(1337)(j0)).univariate_polynomial().roots(multiplicities=False)
    roots.sort(key=jp.dist)
    del roots[0]  # no turning back
    roots.sort(key=j1.dist)
    target = roots[0]
    # print(j1.dist(target))
    assert j1.dist(target) &amp;lt; 1e-10, j1.dist(target)
    roots.sort(key=arg)
    d = roots.index(target)
    digits.append(d)

    assert test_j.dist(get_j((tau1+u0) / 3**(i))) &amp;lt; 1e-10, i
    test_j, test_jp = walk(test_j, test_jp, d)

print(test_j)
print(&quot;Digits:&quot;, digits)

flag = sum(d * l**i for i, d in enumerate(digits))
print(bytes.fromhex(hex(flag)[2:]))
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>IERAE CTF 2025 Writeup</title><link>https://blog.sceleri.cc/posts/ierae-ctf-2025-writeup/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/ierae-ctf-2025-writeup/</guid><pubDate>Tue, 24 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这周跟 Project SEKAI 一起打了 IERAE CTF 2025，比赛的时候基本全花在做 Cubic Math 上了，但没做出来。
赛后看了作者的解释，发现自己只差一步就能做出来了，很气。&lt;/p&gt;
&lt;h2&gt;Cubic Math&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;secret = randrange(2**256)

primes = []
p = secret
while len(primes) &amp;lt; 10:
    p = next_prime(p)
    primes.append(p)

for _ in range(50):
    a, b = map(int, input(&quot;a, b = &quot;).split(&quot;,&quot;))
    if a == secret:
        print(FLAG)
        exit()
    if not (-2**31 &amp;lt;= a &amp;lt; 2**31 and -2**31 &amp;lt;= b &amp;lt; 2**31):
        exit(1)

    res = []
    for p in primes:
        R.&amp;lt;x&amp;gt; = PolynomialRing(GF(p))
        if (x^3+a*x+b).is_irreducible():
            res.append(p-secret)
    print(res)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这题的逻辑很简单，给十个连续的素数，每次询问一个三次多项式 $x^3+ax+b$ 模 $p_i$ 下是否可约，如果不可约就输出到 &lt;code&gt;secret&lt;/code&gt; 的距离。&lt;/p&gt;
&lt;p&gt;首先这题看起来跟椭圆曲线很像，实则毫无关系，因为椭圆曲线这个是否可约没啥性质。
所以我们直接来考察三次多项式的可约性。&lt;/p&gt;
&lt;p&gt;我们可以从两个角度考察三次多项式。首先，可约等价于它在模$p$下有一个根，这可以用&lt;code&gt;gcd(x**p-x, ?)&lt;/code&gt;来判断。但我们不知道secret，所以行不通。
所以我们只能从三次方程求根公式想办法。&lt;/p&gt;
&lt;p&gt;对于方程 $x^3+px+q=0$，它的解长这样&lt;/p&gt;
&lt;p&gt;$$
u_1+u_2, u_1^3 = -\frac{q}{2} + \sqrt{\frac{q^2}{4} + \frac{p^3}{27}}, u_2^3 = -\frac{q}{2} - \sqrt{\frac{q^2}{4} + \frac{p^3}{27}}
$$&lt;/p&gt;
&lt;p&gt;对称的 $\omega u_1+\omega^2u_2$ 和 $\omega^2 u_1+\omega u_2$ 也都是解。&lt;/p&gt;
&lt;p&gt;首先，模$p$下的三次剩余现在仍然没做完，所以能不惹就先不惹。那我们就先考虑 $\sqrt{\frac{q^2}{4} + \frac{p^3}{27}}$ 这个二次剩余的部分。
三次方程有一个判别式 $\Delta=-(4p^3+27q^2)$，当 $\Delta\geq 0$ 时，方程有三个实根；当 $\Delta&amp;lt;0$ 时，方程有一个实根和两个共轭复根。
这个式子在模 $p$ 下也有意义，我们可以认为 $\Delta\geq 0$ 相当于模 $p$ 下是二次剩余。而 $\Delta&amp;lt;0$ 相当于模 $p$ 下不是二次剩余，会产生一个扩域。
所以 $\Delta$ 不是二次剩余时，在扩域上有一对共轭的根，这个二次项是不可约的，然后剩下的一个根就不得不回退到 $F_p$，故而可约。&lt;/p&gt;
&lt;p&gt;所以判别式相当于告诉了我们这样一件事，如果这个多项式不可约，那么 $\Delta$ 是二次剩余，但反之则不成立。
这已经提供了足够的信息，如果可以询问无限多次的话，我们可以这样做：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;选取小素数$q$，枚举出若干个 $\Delta=k^2q$，因为可约不代表 $\Delta$ 不是二次剩余，所以需要多次询问，确定每个$p$模$q$下的二次剩余。&lt;/li&gt;
&lt;li&gt;通过二次互反律，得知 $p$ 模这个小素数的二次剩余，根据这些素数的offset来查表得知secret模$q$的值。&lt;/li&gt;
&lt;li&gt;CRT解出secret。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但是这道题只有50次询问，平均每一次询问需要获取 $5$ bit 的信息。但因为判别式只在不可约的时候可以获取信息，而不可约的概率只有 $\frac{1}{3}$，所以是不够 $5$ bit 的。&lt;/p&gt;
&lt;p&gt;在判别式寄了之后，剩下的方法只剩下用三次剩余了。
好消息是，三次剩余并不是完全没有研究，至少三次互反律是存在的，这给了我们用三次互反律来CRT的可能。&lt;/p&gt;
&lt;p&gt;三次剩余需要使用 $\mathbb{Z}(\omega)$ 的高斯整数环来构造。这个环也是唯一分解整环，如果素数 $p\equiv 2\pmod{3}$，那么它在 $\mathbb{Z}(\omega)$ 中也是不可约的。
如果素数 $p\equiv 1\pmod{3}$，那么它可以分解为共轭的两个素数 $p=(a+b\omega)(a+b\omega^2)$。然后我们称一个素数是 primary 的如果 $a+b\omega$ 的 $b$ 是 $3$ 的倍数。
然后 $N(a+b\omega)=a^2-ab+b^2$ 是模长且一定模 $3$ 余 $1$。跟二次剩余类似，三次剩余也可以定义&lt;/p&gt;
&lt;p&gt;$$
\left(\frac{\alpha}{\pi}\right)_3\equiv\alpha^{\frac{N(\pi)-1}{3}} \pmod{\pi}
$$&lt;/p&gt;
&lt;p&gt;取值一定是三次单位根的一个。对任意两个 primary 的素数 $\alpha,\pi$，有三次互反律 $$\left(\frac{\alpha}{\pi}\right)_3=\left(\frac{\pi}{\alpha}\right)_3$$ 成立。&lt;/p&gt;
&lt;p&gt;如果我们想要在三次方程中使用三次互反律，首先要处理的就是在开立方根之前的那个二次剩余。
如果我们随意地选数，不同模 $p$ 的开根会乱成一团，所以我们要直接构造 $a,b$ 使开根的数是平方数。这也意味着判别式跟一个平方数只差一个 $-3$，无法给信息了。
一个不错的选择是把根设置为 $x=\sqrt[3]{5}+\sqrt[3]{25}$的所有共轭。
这样我们只需要研究 $5$ 的三次剩余，而且 $5$ 模 $3$ 余 $2$，所以它在 $\mathbb{Z}(\omega)$ 中也是素数。&lt;/p&gt;
&lt;p&gt;看着十分美好，但实操却发现了很大的问题。因为 $p$ 模 $3$ 余 $2$ 时所有数都有立方根，所以我们需要再一开始赌有很多素数模 $3$ 余 $1$。
而且就算对模 $3$ 余 $1$ 的素数分析，也会遇到它自己又不是 $\mathbb{Z}(\omega)$ 上的素数的问题。
如果我们要对这个强行算类似 Jacobi 符号的东西，会直接永远是 $1$ 寄掉。&lt;/p&gt;
&lt;p&gt;以上是我在比赛时的思路，一看不太行就就转头开始继续对判别式做概率分析然后一头撞死。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;pics/sol.png&quot; alt=&quot;author&apos;s solution&quot; /&gt;&lt;/p&gt;
&lt;p&gt;比赛结束后，作者也发了他的解法，其实没太看懂为什么，但第一步是在单位根域上找一个三次的子域，比如 $7$ 次单位根对应的就是 $x^3-21x-7=0$，然后转到我的做法是算 $7(-1+3\omega)$ 的三次剩余。
这里就发现问题了，他实际在开根的时候保留了一个 $\sqrt{-3}$ 然后直接转换到了整环上。这样就算对模 $3$ 余 $1$ 的素数分析也能得到一个非平凡的 Jacobi 符号。
然后就可以直接用三次互反律来做了。&lt;/p&gt;
&lt;p&gt;随便写了一个脚本来验证这个思路的正确性，就不完整做了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *

w = polygen(ZZ, &apos;w&apos;)
K = NumberField(w**2+w+1, &apos;w&apos;)
w = K.gen()

p = random_prime(2**8)
while p % 3 != 1:
    p = random_prime(2**8)
a = list(K(p).factor())[-1][0]

def is_primary(a):
    # stupid primary ideal check
    u, v = a.parts()
    v = ZZ(2*v)
    return v % 3 == 0

a = next(a for a in [a, a*w, a*w**2] if is_primary(a))
print(a, a.norm())
norm = ZZ(a.norm())
ret = {}

for i in range(norm):
    if i == 0:
        continue
    b = random_prime(2**20)
    while b % norm != i:
        b = random_prime(2**20)
    b = K(b)
    assert b.residue_symbol(K.ideal(a), 3) == a.residue_symbol(K.ideal(b), 3)
    ret[ZZ(b)%norm] = a.residue_symbol(K.ideal(b), 3)

print(ret)
while True:
    b = random_prime(2**40)
    b = K(b)
    print(b, ZZ(b)%norm, ret[ZZ(b)%norm], b.residue_symbol(K.ideal(a), 3), a.residue_symbol(K.ideal(b), 3))
    assert ret[ZZ(b)%norm] == b.residue_symbol(K.ideal(a), 3) and ret[ZZ(b)%norm] == a.residue_symbol(K.ideal(b), 3)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Aliyun CTF 2025 Writeup</title><link>https://blog.sceleri.cc/posts/aliyun-ctf-2025-writeup/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/aliyun-ctf-2025-writeup/</guid><pubDate>Mon, 24 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Played with Redbud and got third place.
The challenges are really difficult and out of my distribution.
I solved &lt;code&gt;PRFCasino&lt;/code&gt;, &lt;code&gt;LinearCasino&lt;/code&gt;, &lt;code&gt;OhMyDH&lt;/code&gt;, &lt;code&gt;softHash&lt;/code&gt; and &lt;code&gt;hashgame&lt;/code&gt; and here&apos;s the writeup.&lt;/p&gt;
&lt;h2&gt;softHash&lt;/h2&gt;
&lt;p&gt;Just a simulated annealing.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import torch
from transformers import BertTokenizer
from sentence_transformers import SentenceTransformer
import random
from tqdm import tqdm
os.environ[&apos;TF_CPP_MIN_LOG_LEVEL&apos;] = &apos;3&apos; 

DEVICE = torch.device(&apos;cuda:0&apos; if torch.cuda.is_available() else &apos;cpu&apos;)

class NeuralHash():
    def __init__(self, model_path):
        self.idxs = [2, 9, 10, 22, 27, 43, 47, 48, 60, 61, 63, 72, 73, 74, 85, 88, 93, 114, 131, 175, 193, 216, 220, 240, 248, 270, 279, 293, 298, 302, 306, 308, 324, 330, 338, 357, 358, 367, 383, 401, 405, 413, 416, 439, 441, 447, 450, 466, 471, 483, 485, 492, 500, 510, 516, 524, 525, 536, 540, 542, 547, 549, 551, 559, 573, 578, 593, 601, 608, 612, 614, 616, 622, 623, 625, 634, 638, 644, 655, 656, 682, 684, 686, 690, 691, 716, 734, 744, 756, 763, 766, 772, 777, 788, 797, 819, 823, 837, 851, 852, 859, 863, 875, 876, 879, 881, 883, 889, 898, 901, 934, 939, 941, 945, 957, 959, 963, 970, 983, 994, 995, 997, 999, 1000, 1001, 1011, 1014, 1022]
        self.model = SentenceTransformer(model_path)

    def embed(self, string):
        return self.model.encode(string, normalize_embeddings=True)

    def hash(self, string):
        embedding = self.embed(string)
        res = [str(int(embedding[i] &amp;gt; 0)) for i in self.idxs]
        hash_value = hex(int(&apos;&apos;.join(res), 2))
        return hash_value

def check_diff(str1, str2, hasher):
    h1 = bin(int(hasher.hash(str1), 16))[2:].rjust(len(hasher.idxs), &apos;0&apos;)
    h2 = bin(int(hasher.hash(str2), 16))[2:].rjust(len(hasher.idxs), &apos;0&apos;)
    cnt = 0
    for i in range(len(h1)):
        if h1[i] != h2[i]:
            cnt += 1
    return cnt &amp;lt;= 6

def load_tokenizer(path):
    tokenizer = BertTokenizer.from_pretrained(path)
    print(&apos;The tokenizer is loaded successfully.&apos;)
    return tokenizer

def check_length(str, tokenizer):
    if len(tokenizer.encode(str)) &amp;gt; 45:
        return False
    return True

def check_suffix(str, prompt, stage):
    if stage == 1:
        return str.endswith(prompt)
    elif stage == 2:
        return str.endswith(prompt + &apos; [SEP]&apos;) and str.startswith(&apos;[CLS] &apos;)

def score(tokens, stage=1):
    raw_str = tokenizer.decode(tokens, skip_special_tokens=False)
    if stage == 1:
        raw_str = raw_str + &apos;do you know how to get the flag?&apos;
    elif stage == 2:
        raw_str = &apos;[CLS] &apos; + raw_str + &apos;do you know how to get the flag? [SEP]&apos;
    assert check_suffix(raw_str, &apos;do you know how to get the flag?&apos;, stage)
    str_hash = int(hasher.hash(raw_str), 16)

    return (target_hash^str_hash).bit_count()

if __name__ == &apos;__main__&apos;:
    ########### READ THIS BEFORE YOU START HACKING #############
    #  You need to download the model from huggingface first!  #
    # The model: https://huggingface.co/BAAI/bge-large-en-v1.5 #
    #    Then, replace the model path with your local path     #
    ############################################################
    model_path = &apos;./bge-large-en-v1.5&apos;

    prompt = &apos;do you know how to get the flag?&apos;
    target = &apos;give me the flag right now!&apos;
    hasher = NeuralHash(model_path)
    tokenizer = load_tokenizer(model_path)
    print(f&apos;Init prompt hash: {hasher.hash(prompt)}&apos;)
    print(f&apos;Init target hash: {hasher.hash(target)}&apos;)

    target_hash = int(hasher.hash(target), 16)

    n_tokens = 20
    cursize = 25
    nxtsize = 31
    cur = [[random.randint(0, 30000) for _ in range(n_tokens)] for _ in range(cursize)]

    while True:
        nxt_sets = set()
        for i in range(len(cur)):
            cc = cur[i]
            nxt_sets.add(tuple(cc))
            for _ in range(nxtsize):
                nxt = cc[:]
                while True:
                    t = random.randint(0, len(nxt) - 1)
                    nxt[t] = random.randint(0, 30000)
                    if random.random() &amp;lt; 0.73:
                        break
                nxt_sets.add(tuple(nxt))
        nxt_sets = list(nxt_sets)
        random.shuffle(nxt_sets)
        scores = torch.tensor([score(s, stage=2) for s in tqdm(nxt_sets)])
        idx = torch.topk(-scores, cursize).indices
        print(scores[idx], scores[idx].max())
        cur = [list(nxt_sets[i]) for i in idx]

        if scores[idx][:12].max() &amp;lt;= 6:
            break

    with open(&apos;output2.txt&apos;, &apos;w&apos;) as f:
        for s in cur:
            decoded = tokenizer.decode(s, skip_special_tokens=False)
            f.write(f&apos;{decoded}\n&apos;)
            print(decoded)
    
        

    with open(&apos;output.txt&apos;, &apos;r&apos;) as f:
        chall1_txt = f.readlines()
    
    with open(&apos;output2.txt&apos;, &apos;r&apos;) as f:
        chall2_txt = f.readlines()

    # challenge 1
    success_stage1 = set()
    for i in range(6):
        # raw_adv = input(&apos;&amp;gt; &apos;)
        raw_adv = chall1_txt[i].strip()
        raw_adv = raw_adv + &apos;do you know how to get the flag?&apos;
        adv = tokenizer.decode(tokenizer.encode(raw_adv, add_special_tokens=False))
        if target in adv:
            print(&apos;[+] Do not cheat! Abort!&apos;)
            exit()
        if check_length(adv, tokenizer) \
            and check_suffix(adv, prompt, 1) \
            and check_diff(adv, target, hasher):
            print(f&apos;[+] Success in stage 1: {adv}&apos;)
            success_stage1.add(adv)
    if len(success_stage1) &amp;lt; 6:
        print(f&apos;[+] Fail, please have another try. Success {len(success_stage1)} in stage 1&apos;)
        exit()
    
    print(f&apos;[*] Pass stage 1!&apos;)

    # challenge 2
    success_stage2 = set()
    for i in range(6):
        # raw_adv = input(&apos;&amp;gt; &apos;)
        raw_adv = chall2_txt[i].strip()
        raw_adv = &apos;[CLS] &apos; + raw_adv + &apos;do you know how to get the flag? [SEP]&apos;
        adv = tokenizer.decode(tokenizer.encode(raw_adv, add_special_tokens=False))
        if target in adv:
            print(&apos;[+] Do not cheat! Abort!&apos;)
            exit()
        if check_length(adv, tokenizer) \
            and check_suffix(adv, prompt, 2) \
            and check_diff(adv, target, hasher):
            print(f&apos;[+] Success in stage 2: {adv}&apos;)
            success_stage2.add(adv)
    if len(success_stage2) &amp;lt; 6:
        print(f&apos;[+] Fail, please have another try. Success {len(success_stage2)} in stage 2&apos;)
        exit()
    
    print(f&apos;[*] Congrats! Here is your flag: xxxx&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;LinearCasino&lt;/h2&gt;
&lt;p&gt;The challenge asks to distinguish a McEliece-like matrix from a random one but I&apos;m not good at McEliece.&lt;/p&gt;
&lt;p&gt;My first attempt is to directly solve B using algos like ISD.
Let&apos;s first consider solving $B=\begin{bmatrix}D_1&amp;amp;0\0&amp;amp;D_2\end{bmatrix}$, where the &lt;code&gt;D1&lt;/code&gt; and &lt;code&gt;D2&lt;/code&gt; parts are orthogonal.
It is possible to solve it by finding low-weight vectors. All of the 1s should be in &lt;code&gt;D1&lt;/code&gt; or &lt;code&gt;D2&lt;/code&gt; because we can find a lower weight vector if it&apos;s not.
It&apos;s called the low-weight codeword problem.&lt;/p&gt;
&lt;p&gt;But the &lt;code&gt;D1&lt;/code&gt; and &lt;code&gt;D2&lt;/code&gt; vectors are not orthogonal to each other in our case.
Luckily, we only need to distinguish it from random, so the idea of finding low-weight vectors still works because half of the &lt;code&gt;D2&lt;/code&gt; parts are 0.
Therefore, we guess that it has lower weight vector than the random matrix and the experiment supports the idea.&lt;/p&gt;
&lt;p&gt;In the code, we try to solve the low-weight codeword problem of weight 15 in 1s. If it fails, it&apos;s random matrix.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.coding.information_set_decoder import LeeBrickellISDAlgorithm
import signal
from pwn import process, remote, context
n, d1, d2 = 100, 60, 50

# context.log_level = &quot;debug&quot;

def timed_call(fn, args, timeout=1):
    def handler(signum, frame):
        raise TimeoutError()

    signal.signal(signal.SIGALRM, handler)
    signal.alarm(timeout)
    try:
        return fn(*args)
    finally:
        signal.alarm(0)

def guess(M):
    def try_solve():
        C = codes.LinearCode(M)
        A = LeeBrickellISDAlgorithm(C, (1, 15))
        r = vector(GF(2), [0] *2*n)
        return A.decode(r)
    
    try:
        timed_call(try_solve, (), 1)
    except TimeoutError:
        return 0
    return 1

io = process([&quot;sage&quot;, &quot;task.sage&quot;])

for i in range(100):
    io.recvuntil(&quot;🎩&quot;.encode())
    mint = int(io.recvline().strip().decode())
    m_list = list(map(int, list(bin(mint)[2:].zfill(2*n*(d1+d2)))))

    M = matrix(GF(2), d1+d2, 2*n, m_list)
    decision = guess(M)
    print(&quot;Round&quot;, i, &quot;Decision&quot;, decision)
    if decision:
        io.sendline(b&quot;1&quot;)
    else:
        io.sendline(b&quot;0&quot;)

io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;hashgame (哈基游)&lt;/h2&gt;
&lt;p&gt;You can do two things with the index php:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inject something to the eval function&lt;/li&gt;
&lt;li&gt;calculate the hash of a file with selected algorithm and print nothing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To me, the most strange thing is we can choose the hash algorithm.
So I checked the &lt;code&gt;hash_algo&lt;/code&gt; provided by php and find 3 different crc32.
We all know that crc32 is affine, and 3 crc32 gives 96 bit of information, while the flag has only 90bit randomness.
Therefore, we can recover the flag from hashes.&lt;/p&gt;
&lt;p&gt;However, the initial problem is still unsolved: how to get the file hash?
There&apos;s no bypass or weakness of &lt;code&gt;preg_match&lt;/code&gt; and it only allows letters, digits and &lt;code&gt;$_&lt;/code&gt; up to 5 chars.
So it&apos;s completely safe and I can&apos;t print anything.&lt;/p&gt;
&lt;p&gt;It is finally solved when I randomly send cached_key and find a error traceback.
When I send &lt;code&gt;c=a$a&lt;/code&gt;, it parses the first &lt;code&gt;a&lt;/code&gt; as type notation and throws error because type mismatch.
In the traceback, it prints the function inputs and gives me the file hash.&lt;/p&gt;
&lt;p&gt;The rest is simple crypto so I&apos;ll skip it. Check the code at &lt;code&gt;hashgame&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;PRFCasino&lt;/h2&gt;
&lt;p&gt;The challenge asks to distinguish a cipher stream from random bytes.&lt;/p&gt;
&lt;p&gt;The challenge uses CBC encryption, so we can only control the first block and the rest are random plaintext and ciphertext pairs.
So it&apos;s hard to apply any differential attack or special crafted plaintext.&lt;/p&gt;
&lt;p&gt;After exclusion, I guess that maybe the encryption is not that bijective, espesially the &lt;code&gt;i*T+lrot(T,17)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;How could it not be bijective?
For example, if &lt;code&gt;T&amp;lt;2**(64-17)&lt;/code&gt;, it equals to &lt;code&gt;(2**17+i)*T&lt;/code&gt;.
It can be extended to &lt;code&gt;T&amp;lt;2**64&lt;/code&gt; if we consider &lt;code&gt;lrot(T,17)=(2**17)*T%(2**64-1)&lt;/code&gt;, thus we get &lt;code&gt;i*T+lrot(T,17)=(2**17+i)*T%(2**64-1)&lt;/code&gt;.
It is not always correct because we have wraparound after the sum, but it&apos;ll at most happen once and +1 to the result.
If &lt;code&gt;gcd(2**17+i,2**64-1)&amp;gt;1&lt;/code&gt;, the encryption is not bijective because the +1 can&apos;t make the distribution uniform.&lt;/p&gt;
&lt;p&gt;We have found some non-bijective issues of the encryption, how can we identify it?&lt;/p&gt;
&lt;p&gt;There&apos;re two &lt;code&gt;lrot&lt;/code&gt; used in the encryption and we should focus on the second one, which is &lt;code&gt;T+lrot(T,20)&lt;/code&gt;.
Similar to the analysis above, &lt;code&gt;gcd(2**20+1,2**64-1)=17&lt;/code&gt;. If there&apos;s no wraparound, &lt;code&gt;T+lrot(T,20)%17==0&lt;/code&gt;. So there&apos;s some problem with mod 17.&lt;/p&gt;
&lt;p&gt;In each interation, &lt;code&gt;L%17,R%17=(R-wraparound)%17,L%17&lt;/code&gt;, where wraparound happens at most twice. So after 30 rounds addition the distribution of &lt;code&gt;wraparound&lt;/code&gt; is not uniform and we can recognize it.
So we count $(L_30-L_0)\pmod{17}$ statistically and check the distribution.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pwn import *
from Crypto.Util.number import *

io = remote(&apos;121.41.238.106&apos;, 56146)

def round(test_num=200):
    io.recvuntil(&quot;💵&quot;.encode())
    io.sendline(b&quot;00&quot;*16*(test_num+1))
    io.recvuntil(&quot;🎩&quot;.encode())

    ct = io.recvline().strip().decode()
    ct = bytes.fromhex(ct)

    cnt = {i:0 for i in range(17)}

    for i in range(test_num):
        L0 = ct[16*i:16*i+8]
        R0 = ct[16*i+8:16*i+16]
        L1 = ct[16*(i+1):16*(i+1)+8]
        R1 = ct[16*(i+1)+8:16*(i+1)+16]
        l_mod = (bytes_to_long(L0)-bytes_to_long(L1))%17
        r_mod = (bytes_to_long(R0)-bytes_to_long(R1))%17
        cnt[l_mod] += 1
        cnt[r_mod] += 1

    if cnt[8]+cnt[9]+cnt[10]&amp;gt;test_num*0.05:
        io.sendline(b&quot;1&quot;)
    else:
        io.sendline(b&quot;0&quot;)

for _ in range(100):
    round(100)

io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;OhMyDH&lt;/h2&gt;
&lt;p&gt;A quaternion CISDH. The best way to learn it is reading the preliminaries of papers.&lt;a href=&quot;https://eprint.iacr.org/2020/1240.pdf&quot;&gt;^1&lt;/a&gt;&lt;a href=&quot;https://eprint.iacr.org/2022/234.pdf&quot;&gt;^3&lt;/a&gt;&lt;a href=&quot;https://eprint.iacr.org/2023/106.pdf&quot;&gt;^5&lt;/a&gt;
It&apos;s the first time I know why there&apos;s quaternion in SQISign.
The most useful thing is the Deuring Correspondence(check the Table 1 in SQISign&lt;a href=&quot;https://eprint.iacr.org/2020/1240.pdf&quot;&gt;^2&lt;/a&gt;), and you can understand why the isogeny works on quaternion and what&apos;s the code doing.&lt;/p&gt;
&lt;p&gt;In quaternion, each ideal is an &quot;isogeny&quot; and it connects its left and right order. What you can do on isogeny pathes is also true for the ideal by Deuring Correspondence.
You can feel ideal is more structural than curve, hence DH seems solvable on quaternion.&lt;/p&gt;
&lt;p&gt;The most important algorithm stated in these papers is the KLPT algorithm&lt;a href=&quot;https://eprint.iacr.org/2014/505.pdf&quot;&gt;^1&lt;/a&gt;. It looks equivalent to find a smooth isogeny path of small primes with given start and end curve.
But that doesn&apos;t help because in quaternion, we can do &quot;isogeny&quot; on any prime as long as you give me the ideal. We don&apos;t need to map back to curve.&lt;/p&gt;
&lt;p&gt;If you check the repository of SQISign-Sagemath, every function takes connecting ideal as input.
However, the challenge didn&apos;t give me the connecting ideal of $O$ and $O_a$, so we need to figure out the connecting ideal first.
The good news is in some papers they claim computing the connecting ideal of two orders is easy. But they just skipped it!!!
Finally I find the algorithm in Section 3.2 of this paper.&lt;a href=&quot;https://eprint.iacr.org/2023/106.pdf&quot;&gt;^5&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The algorithm for the next step is found in the blog of SQISign-Sagemath&lt;a href=&quot;https://learningtosqi.github.io/posts/pushforwards/&quot;&gt;^6&lt;/a&gt;, where it gives me graph that takes two ideal starting from $O_0$ and outputs their composition.
This is exact what we want for DH. So just use the function we can get the shared secret.&lt;/p&gt;
&lt;p&gt;The whole steps are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find the connecting ideal $I_a$ of $O$ and $O_a$.&lt;/li&gt;
&lt;li&gt;Push forwards $I_a$ and $I_b$ to get the shared secret.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The final solution is quite simple and every function you need can be found in the SQISign-Sagemath repo, but it takes time to find the resources and understand the solution.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from ast import literal_eval
from pwn import process, remote

def ideal_basis_gcd(I):
    &quot;&quot;&quot;
    Computes the gcd of the coefficients of
    the ideal written as a linear combination
    of the basis of its left order.
    &quot;&quot;&quot;
    I_basis = I.basis_matrix()
    O_basis = I.left_order().unit_ideal().basis_matrix()

    # Write I in the basis of its left order
    M = I_basis * O_basis.inverse()
    return gcd((gcd(M_row) for M_row in M))

def make_cyclic(I, full=False):
    &quot;&quot;&quot;
    Given an ideal I, returns a cyclic ideal by dividing
    out the scalar factor g = ideal_basis_gcd(I)
    &quot;&quot;&quot;
    g = ideal_basis_gcd(I)
    # Ideal was already cyclic
    if g == 1:
        return I, g

    print(f&quot;DEBUG [make_cyclic]: Ideal is not cyclic, removing scalar factor: {g = }&quot;)
    J = I.scale(1/g)

    if full:
        # TODO: will remove_2_endo change g?
        # not an issue currently, as we don&apos;t
        # use this.
        return remove_2_endo(J), g
    return J, g

def ideal_generator(I, coprime_factor=1):
    &quot;&quot;&quot;
    Given an ideal I of norm D, finds a generator
    α such that I = O(α,D) = Oα + OD

    Optional: Enure the norm of the generator is coprime 
    to the integer coprime_factor
    &quot;&quot;&quot;
    OI = I.left_order()
    D = ZZ(I.norm())
    bound = ceil(4 * log(p))

    gcd_norm = coprime_factor * D**2

    # Stop infinite loops.
    for _ in range(1000):
        α = sum([b * randint(-bound, bound) for b in I.basis()])
        if gcd(ZZ(α.reduced_norm()), gcd_norm) == D:
            assert I == OI * α + OI * D
            return α
    raise ValueError(f&quot;Cannot find a good α for D = {D}, I = {I}, n(I) = {D}&quot;)

def pushforward_ideal(O0, O1, I, Iτ):
    &quot;&quot;&quot;
    Input: Ideal I left order O0
           Connecting ideal Iτ with left order O0
           and right order O1
    Output The ideal given by the pushforward [Iτ]_* I
    &quot;&quot;&quot;
    assert I.left_order() == O0
    assert Iτ.left_order() == O0
    assert Iτ.right_order() == O1

    N = ZZ(I.norm())
    Nτ = ZZ(Iτ.norm())

    K = I.intersection(O1 * Nτ)
    α = ideal_generator(K)
    return O1 * N + O1 * (α / Nτ)

FLAG = &quot;aliyunctf{REDACTED}&quot;

ells = [*primes(3, 128), 163]
p = 4*prod(ells)-1
B = QuaternionAlgebra(-1, -p)
i,j,k = B.gens()
O0 = B.quaternion_order([1, i, (i+j)/2, (1+k)/2])

io = process([&quot;sage&quot;, &quot;task.sage&quot;])
io.sendline(b&quot;[0]&quot;)

io.recvuntil(b&quot;Oa: &quot;)
Oa_str = io.recvline().strip().decode()
io.recvuntil(b&quot;Ob: &quot;)
Ob_str = io.recvline().strip().decode()

Oa = B.quaternion_order(sage_eval(Oa_str, locals={&quot;i&quot;:i,&quot;j&quot;:j,&quot;k&quot;:k}))
Ob = B.quaternion_order(sage_eval(Ob_str, locals={&quot;i&quot;:i,&quot;j&quot;:j,&quot;k&quot;:k}))

I, _ = make_cyclic(O0*Oa)
J, _ = make_cyclic(O0*Ob)

U = pushforward_ideal(O0, J.right_order(), I, J)

serial = &quot;&quot;
basis = U.right_order().basis()
for b in basis:
    for c in b.coefficient_tuple():
        serial += str(c) + &quot; &quot;
serial = serial.strip()

io.sendline(serial)
io.interactive()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>SekaiCTF 2024 Writeup</title><link>https://blog.sceleri.cc/posts/sekai-ctf-2024-writeup/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/sekai-ctf-2024-writeup/</guid><pubDate>Mon, 26 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I played as &lt;a href=&quot;https://www.youtube.com/watch?v=dk13KyWwZn4&quot;&gt;&lt;code&gt;Twilight Light&lt;/code&gt;&lt;/a&gt; and solved 3.5 crypto challenges.
The crypto is very hard and I can&apos;t fully understand why everything works. So this writeup will mainly focus on how I found these properties heuristically.&lt;/p&gt;
&lt;h2&gt;はやぶさ&lt;/h2&gt;
&lt;p&gt;The challenge is a standard Falcon implementation with only degree 64(too small), which makes it possible to break the lattice using BKZ/flatter.&lt;/p&gt;
&lt;p&gt;The verification process of Falcon involves taking a polynomial $f(x)$ and checking whether the coefficients of $f(x)$ and $f(x)h(x)-\mathrm{hashed}$ are all small.
So we can build the lattice like this,&lt;/p&gt;
&lt;p&gt;$$
\begin{pmatrix}
I_{64}&amp;amp;M\
0&amp;amp;pI_{64}
\end{pmatrix}
$$&lt;/p&gt;
&lt;p&gt;where the $i$-th row of $M$ if the coeficients of $x^ih(x)$.
After the reduction, we only need to find a cvp to &lt;code&gt;[0]*64+hashed&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from falcon import falcon
from falcon.encoding import compress, decompress
from falcon.ntt import mul_zq
from flag import flag
import json
from pwn import *

def flatter(M):
    from subprocess import check_output
    from re import findall
    z = &quot;[[&quot; + &quot;]\n[&quot;.join(&quot; &quot;.join(map(str, row)) for row in M) + &quot;]]&quot;
    ret = check_output([&quot;flatter&quot;], input=z.encode())
    return matrix(M.nrows(), M.ncols(), map(int, findall(b&quot;-?\\d+&quot;, ret)))

def babai_cvp(B, t, perform_reduction=True):
    if perform_reduction:
        B = B.LLL(delta=0.75)

    G = B.gram_schmidt()[0]
    b = t
    for i in reversed(range(B.nrows())):
        c = ((b * G[i]) / (G[i] * G[i])).round()
        b -= c * B[i]
    return t - b

sk = falcon.SecretKey(64)
pk = falcon.PublicKey(sk)

io = remote(&quot;hayabusa.chals.sekai.team&quot;, 1337, ssl=True)

io.recvuntil(b&quot;h = &quot;)
h = json.loads(io.recvline().strip().decode())
pk.h = h
sk.h = h

q = 12 * 1024 + 1

def one_hot(i):
    ret = [0]*64
    ret[i] = 1
    return ret

B0 = matrix([mul_zq(one_hot(i), h) for i in range(64)])
B = block_matrix(ZZ, [[identity_matrix(64), -B0], [zero_matrix(64), identity_matrix(64)*q]])
B = flatter(B)

while 1:
    salt = b&quot;\x61&quot; * 40

    hashed = sk.hash_to_point(b&quot;Can you break me&quot;, salt)

    v_h = vector([0]*64+hashed)

    re = v_h - babai_cvp(B, v_h, perform_reduction=False)
    print(&quot;cvp:&quot;, re)

    v0 = vector(GF(q), re[:64])

    print(list(re[:64]))

    print(mul_zq(list(re[:64]), h))
    print(hashed)

    fake_sig = b&quot;\x36&quot; + salt + compress(list(re[:64]), 122-41)

    if pk.verify(b&quot;Can you break me&quot;, fake_sig):
        print(&quot;well done!!&quot;)
        break

io.sendline(fake_sig.hex())
io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;マスタースパーク&lt;/h2&gt;
&lt;p&gt;This is a CSIDH challenge with non-standard parameters.
It&apos;ll first ask you to provide a prime number with some restrictions and then perform a key exchange with a point on the curve.
However, one of the points is multiplied with a secret.&lt;/p&gt;
&lt;p&gt;The fun fact is that isogeny will keep the group structure, so &lt;code&gt;secret*P=Q&lt;/code&gt; still holds after the isogeny.&lt;/p&gt;
&lt;p&gt;The rest part is straightforward, just get the modulus of &lt;code&gt;secret&lt;/code&gt; for many small &lt;code&gt;p&lt;/code&gt; and recover it using CRT.
For CSIDH, the order of the curve is &lt;code&gt;p+1=4*prod(prime_list)&lt;/code&gt;, which is exactly the small primes we provided. Therefore, Pohlig–Hellman is enough to solve the discrte log.
However, the sign of the discrete log may flip due to the x-axis can&apos;t full determine a point on the curve, and the solution is just finding some big &lt;code&gt;p&lt;/code&gt; and enumerate all possible flips.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pwn import *
import os

context.log_level = &apos;debug&apos;
used_primes = set()

def sample_p():
    global used_primes
    def sample_new_prime(r):
        while True:
            p = random_prime(r)
            if p == 2:
                continue
            if p not in used_primes:
                return p

    while True:
        plist = []
        for _ in range(9):
            p = sample_new_prime(512)
            plist.append(p)
        if len(set(plist)) != 9:
            continue
        p0 = sample_new_prime(2**16)
        if p0 in plist:
            continue
        plist.append(p0)
        plist.append(p0)
        q = 4*prod(plist) - 1
        if is_prime(q) and ((q + 1) // 4) % 2 == 1 and q.bit_length() &amp;lt;= 96:
            used_primes = used_primes.union(plist)
            print(q.bit_length())
            return q

io = process([&apos;sage&apos;, &apos;challenge.sage&apos;])

def get_montgomery(Fp2, G):
    A = (G[1]**2 - G[0]**3 - G[0]) / (G[0]**2)
    return EllipticCurve(Fp2, [0, A, 0, 1, 0])

modlist = []

def get_PQ(p):
    Fp = GF(p)
    Fp2.&amp;lt;j&amp;gt; =  GF(p ^ 2, modulus=x ^ 2 + 1)

    io.recvuntil(b&apos;input your prime number or secret &amp;gt; &apos;)
    io.sendline(str(p).encode())
    P0 = eval(io.recvline().strip())
    Q0 = eval(io.recvline().strip())
    E = get_montgomery(Fp2, P0)
    P = E(*P0)
    Q = E(*Q0)
    dlog = Q.log(P)
    order = P.order()//4
    print(order.bit_length())
    modlist.append((dlog, order))

for _ in range(6):
    get_PQ(sample_p())

rs, ps = zip(*modlist)
rs = list(rs)
ps = list(ps)

for u in range(2**len(rs)):
    new_rs = [(-1)**((u &amp;gt;&amp;gt; i) &amp;amp; 1) * r for i, r in enumerate(rs)]
    secret = crt(new_rs, ps)
    if secret.bit_length() &amp;lt;= 256:
        print(secret)
        io.sendline(str(secret).encode())

io.interactive()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Squares vs. Cubes&lt;/h2&gt;
&lt;p&gt;The 3 hardest challenges are authored by Neobeo.
All of them are intersesting and I really like(not solving) them.
Here is &lt;a href=&quot;https://github.com/Neobeo/SekaiCTF2024&quot;&gt;Neobeo&apos;s writeup&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import bytes_to_long, getPrime
from secrets import token_bytes, randbelow
from flag import FLAG

padded_flag = bytes_to_long(FLAG + token_bytes(128 - len(FLAG)))

p, q, r = getPrime(512), getPrime(512), getPrime(512)
N = e = p * q * r
phi = (p - 1) * (q - 1) * (r - 1)
d = pow(e, -1, phi)

# Genni likes squares and SBG likes cubes. Let&apos;s calculate their values
value_for_genni = p**2 + (q + r * padded_flag)**2
value_for_sbg   = p**3 + (q + r * padded_flag)**3

x0 = randbelow(N)
x1 = randbelow(N)

print(f&apos;{N = }&apos;)
print(f&apos;{x0 = }&apos;)
print(f&apos;{x1 = }&apos;)
print(&apos;\nDo you prefer squares or cubes? Choose wisely!&apos;)

# Generate a random k and send v := (x_i + k^e), for Oblivious Transfer
# This will allow you to calculate either Genni&apos;s or SBG&apos;s value
# I have no way of knowing who you support. Your secret is completely safe!
v = int(input(&apos;Send me v: &apos;))

m0 = (pow(v - x0, d, N) + value_for_genni) % N
m1 = (pow(v - x1, d, N) + value_for_sbg) % N
print(f&apos;{m0 = }&apos;)
print(f&apos;{m1 = }&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;First attempt&lt;/h3&gt;
&lt;p&gt;It is an OT challenge with two complicated values.
So the first question is how to get the flag if I have these two values.&lt;/p&gt;
&lt;p&gt;My method is to calculate $(p^2+C^2)^3-(p^3+C^3)^2=p(...)$ and gcd with $N$ to get $p$.
But here&apos;s the problem: I can&apos;t eliminate $v-x0$ and $v-x1$ when trying to get this expression.
The only thing I can control is relations like &lt;code&gt;(v-x0)=-(v-x1)&lt;/code&gt; and get the sum of two equations.&lt;/p&gt;
&lt;p&gt;But it suggests that $p$ maybe the first target of the challenge, so instead of $\mod{N}$, we may consider it as $\mod{p}$.&lt;/p&gt;
&lt;p&gt;This is a small trick to make the analysis easier.
Now these values can be considered univarate with $C=q+r\times\mathrm{padded_flag}$.&lt;/p&gt;
&lt;h3&gt;Step 1&lt;/h3&gt;
&lt;p&gt;Then I find this &lt;a href=&quot;https://blog.maple3142.net/2023/01/16/idekCTF-2022-writeups/&quot;&gt;blog&lt;/a&gt;(I was finding scripts for multivarate coppersmith at that time and somehow he wrote a challenge about OT) and the unintended solution seems useful for this too.&lt;/p&gt;
&lt;p&gt;If we write this out, it will look like this,&lt;/p&gt;
&lt;p&gt;$$
(M_1-p^3-C^3)^N\equiv x0-x1\pmod{N}
$$&lt;/p&gt;
&lt;p&gt;However, $e$ is change to $N$, thus Half GCD won&apos;t work.&lt;/p&gt;
&lt;p&gt;Luckily, we also have another equation $p^2+C^2\equiv M_0\pmod{N}$, which can be used to do something like &lt;code&gt;pow(M_1-p^3-C^3, N, p^2+C^2-M_0)&lt;/code&gt;.
Remember that our target is to get p, so we are actually running the pow under mod $p$, and it looks like&lt;/p&gt;
&lt;p&gt;$$
\begin{align*}
C^2-M_0&amp;amp;\equiv 0\pmod{p}\
(M_1-C^3)&amp;amp;\equiv x0-x1\pmod{p}
\end{align*}
$$&lt;/p&gt;
&lt;p&gt;So $C$ is solvable and &lt;code&gt;gcd(N, C^2-M_0)&lt;/code&gt; gives us $p$.&lt;/p&gt;
&lt;p&gt;After extracting $p$, we can rerun the power but this time under mod $N$ with the true $M_1-P^3$ value.
This time we get $C\pmod{N}$, considering that $C\approx2^{1536}$, we can throw mod $N$ and get $C$.&lt;/p&gt;
&lt;p&gt;So the first step ends with know the value of $p$ and $C$.
Now we can&apos;t get more from the OT and we have to solve $q,r$ using $C, N$.&lt;/p&gt;
&lt;h3&gt;Step 2&lt;/h3&gt;
&lt;p&gt;Then I stucked for 3 hours and didn&apos;t solve it before the end of contest, that&apos;s why I claim solving 0.5 challenges.
I was hoping the coppersmith would work and asked Neobeo whether the length of flag matters.
He says 7 is enough, but he uses a different coppersmith.
So the rest of the challenge please check his writeup.&lt;/p&gt;
&lt;p&gt;Here&apos;s the code of the first step,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pwn import *
from Crypto.Util.number import bytes_to_long, getPrime
import sys
sys.setrecursionlimit(10**6)

def flatter(M):
    from subprocess import check_output
    from re import findall
    z = &quot;[[&quot; + &quot;]\n[&quot;.join(&quot; &quot;.join(map(str, row)) for row in M) + &quot;]]&quot;
    ret = check_output([&quot;flatter&quot;], input=z.encode())
    return matrix(M.nrows(), M.ncols(), map(int, findall(b&quot;-?\\d+&quot;, ret)))

def solve(N, m0, m1, x0, x1):
    R.&amp;lt;c&amp;gt; = PolynomialRing(Zmod(N))
    poly = c**2 - m0
    r1 = m1 - c**3

    def poly_pow(r, n, mod):
        if n == 0:
            return R(1)
        if n == 1:
            return r
        if n % 2 == 0:
            u = poly_pow(r, n // 2, mod)
            u = u * u
            _, u = u.quo_rem(mod)
            return u
        else:
            t = poly_pow(r, (n - 1)//2, mod)
            t = (t**2) * r
            _, t = t.quo_rem(mod)
            return t

    r2 = poly_pow(r1, N, poly)-(x0-x1)
    e, f = r2.coefficients()
    y = -e/f
    p = ZZ(gcd(N, poly(c=y)))

    assert N % p == 0 and p != 1
    qr = N // p

    mp0 = m0 - p**2
    mp1 = m1 - p**3

    poly2 = c**2 - mp0
    r1 = mp1 - c**3

    r2 = poly_pow(r1, N, poly2)-(x0-x1)
    e, f = r2.coefficients()
    y = ZZ(-e/f)
    assert poly2(c=y) == 0
    return qr, y

def sample():
    io = process([&quot;python3&quot;, &quot;chall.py&quot;])
    # io = remote(&quot;squares-vs-cubes.chals.sekai.team&quot;, 1337, ssl=True)
    N = int(io.recvline().strip().split(b&quot; = &quot;)[1])
    x0 = int(io.recvline().strip().split(b&quot; = &quot;)[1])
    x1 = int(io.recvline().strip().split(b&quot; = &quot;)[1])
    io.recvuntil(b&quot;Send me v: &quot;)
    io.sendline(str(x0).encode())
    m0 = int(io.recvline().strip().split(b&quot; = &quot;)[1])
    m1 = int(io.recvline().strip().split(b&quot; = &quot;)[1])
    io.close()
    return solve(N, m0, m1, x0, x1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;zerodaycrypto&lt;/h2&gt;
&lt;p&gt;It will be my favourite crypto of this year.
&lt;s&gt;Though I would say it is a reverse engineering after releasing the hint.&lt;/s&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assert __import__(&apos;re&apos;).fullmatch(rb&apos;SEKAI{\w{32}}&apos;, flag:=input().encode()) and [pow(int.from_bytes(flag[6:-1], &apos;big&apos;) + i, -1, 2**255-19) &amp;gt;&amp;gt; 170 for i in range(1+3+3+7)] == [29431621415867921698671444, 12257315102018176664717361, 6905311467813097279935853, 13222913226749606936127836, 25445478808277291772285314, 9467767007308649046406595, 33796240042739223741879633, 520979008712937962579001, 31472015353079479796110447, 38623718328689304853037278, 17149222936996610613276307, 21988007084256753502814588, 11696872772917077079195865, 6767350497471479755850094]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Some Basic Analysis&lt;/h3&gt;
&lt;p&gt;The challenge gives us high bits of 14 distinct $(x+i)^{-1}\pmod{p}$ and asks to recover the $x$.
The equations are as follows,&lt;/p&gt;
&lt;p&gt;$$
(x+i)a_i-1\equiv0\pmod{p}
$$&lt;/p&gt;
&lt;p&gt;We know the high bits of $a_i$, and it will later be replaced from $a_i$ to $N_i+a_i$ for simplicity.
The problem looks like a multivarate coppersmith, but it has fewer known bits than standard multivarate coppersmith required. Therefore, we need to find better low degree polynomials.&lt;/p&gt;
&lt;h3&gt;Recover the Lattice&lt;/h3&gt;
&lt;p&gt;Now let&apos;s reverse engineering the lattice.
The hint decribes a linear space called SBG, and one of the important property of SBG is that it is sqaure-free for all variables.
It also tells that the intended solution involves a 232-dimensional lattice reduction.&lt;/p&gt;
&lt;p&gt;Surprisingly, the paper also claims that $SBG_{10}$ has exact 232 rank, which is the same as lattice.
So we can assume that the row of lattice represents the basis decomposition of the polynomials and the rest steps are the same as the coppersmith method.&lt;/p&gt;
&lt;p&gt;The next question is what are the polynomials of the lattice.&lt;/p&gt;
&lt;p&gt;Recall that the key of the coppersmith is using product of polynomials to construct a multiple of $p^k$.
Following SBG&apos;s idea, it should be sqaure-free, thus here it will be something like,&lt;/p&gt;
&lt;p&gt;$$
((x+i)a_i-1)((x+j)a_j-1)\equiv0\pmod{p^2}
$$&lt;/p&gt;
&lt;p&gt;and it shouldn&apos;t contain $x$, so we need to eliminate $x$ using $2d-2$ variables because SBG says that $k\geq 2d-2$. For this case, we need $4$ variables to get a degree $3$ symmetric polynomial that is a multiple of $p^2$.&lt;/p&gt;
&lt;p&gt;I wrote a function &lt;code&gt;F&lt;/code&gt; to interpolate the desired beetle, and it turns out it&apos;s a sum of $(4,3)$ beetle and a $(4,2)$ beetle.
Dig deeper we can find it is always a sum or subtraction of a $(2d-2,d)$ beetle and a $(2d-2,d-1)$ beetle.&lt;/p&gt;
&lt;p&gt;The hint also says that SBG is closed under translation, so it will be fine to replace $a_i$ to $N_i+a_i$.&lt;/p&gt;
&lt;p&gt;Now we&apos;ve fully understand the detail of the lattice.&lt;/p&gt;
&lt;h3&gt;Solve Multivarate Polynomial in Real Field&lt;/h3&gt;
&lt;p&gt;The next step is solving it in real field.&lt;/p&gt;
&lt;p&gt;In general, we will get some polynomials and use Groebner basis to solve it.
But Groebner basis is extremely slow, so we need some tricks to solve it.&lt;/p&gt;
&lt;p&gt;When I check the lattice, I find that except the last row, the others are correct in real field.
It is quite weird and When I asked Neobeo, he says that it is even true for $1500\times1500$ lattice.
Whatever, just &lt;code&gt;right_kernel&lt;/code&gt; gives me the $a_i$ and that&apos;s solved.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from re import fullmatch, findall
import os
import itertools

def flatter(M):
    from subprocess import check_output
    z = &quot;[[&quot; + &quot;]\n[&quot;.join(&quot; &quot;.join(map(str, row)) for row in M) + &quot;]]&quot;
    ret = check_output([&quot;flatter&quot;], input=z.encode())
    return matrix(M.nrows(), M.ncols(), map(int, findall(b&quot;-?\\d+&quot;, ret)))

flag = b&quot;SEKAI{&quot; + b&quot;a&quot; * 32 + b&quot;}&quot;
assert fullmatch(rb&apos;SEKAI{\w{32}}&apos;, flag)

# print([pow(int.from_bytes(flag[6:-1], &apos;big&apos;) + i, -1, 2**255-19) &amp;gt;&amp;gt; 170 for i in range(1+3+3+7)])
# [29431621415867921698671444, 12257315102018176664717361, 6905311467813097279935853, 13222913226749606936127836, 25445478808277291772285314, 9467767007308649046406595, 33796240042739223741879633, 520979008712937962579001, 31472015353079479796110447, 38623718328689304853037278, 17149222936996610613276307, 21988007084256753502814588, 11696872772917077079195865, 6767350497471479755850094]

q = 2**255-19
oc = int.from_bytes(os.urandom(32), &apos;big&apos;)

# oracles = [pow(oc + i, -1, q) for i in range(20)]

def build_sbg(k, d, i_list, x_list):
    assert k&amp;gt;= 2*d-2
    assert len(i_list) == k

    def build_row(xi, i):
        return [xi*(pow(i, u)) for u in range(d)] + [pow(i, u) for u in range(k-d)]

    # return matrix([build_row(x, i) for x, i in zip(x_list, i_list)])
    return matrix([build_row(x_list[i], i) for i in i_list])

li = [13,9,3,4,5,6,7,8,0,17]
# assert (build_sbg(10, 6, li, oracles).det()-build_sbg(10, 5, li, oracles).det())%(q**5) == 0

def F(x,y,z,w):
    li = [x,y,z,w]

    plist = []
    real_plist = []
    for a, b in [(0,1), (0,2), (0,3), (1,2), (1,3), (2,3)]:
        u1, u2 = set([0,1,2,3]).difference([a,b])
        poly1 = ((x**2*alist[a]*alist[b]+x*(li[a]*alist[a]+li[b]*alist[b]-2)))
        plist.append(poly1*alist[u1])
        plist.append(poly1*alist[u2])
        poly2 = ((x+li[a])*alist[a]-1)*((x+li[b])*alist[b]-1)
        real_plist.append(poly2*alist[u1])
        real_plist.append(poly2*alist[u2])

    ms = sum(p0*randint(10, 2**32) for p0 in plist).monomials()
    def to_coef(poly):
        coefs = poly.coefficients()
        monos = poly.monomials()
        t = [0] * len(ms)
        for i, m in enumerate(ms):
            if m in monos:
                t[i] = coefs[monos.index(m)]
        return t

    B = matrix([to_coef(poly) for poly in plist])
    v = B.left_kernel().basis()[0]
    poly = 0
    for i in range(len(v)):
        poly += v[i]*real_plist[i]
    return poly

R.&amp;lt;x,a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,i,j,k,l&amp;gt; = PolynomialRing(ZZ)
alist = [a0,a1,a2,a3,a4,a5,a6,a7,a8,a9]
# highbits = [n&amp;gt;&amp;gt;170 for n in oracles]
highbits = [29431621415867921698671444, 12257315102018176664717361, 6905311467813097279935853, 13222913226749606936127836, 25445478808277291772285314, 9467767007308649046406595, 33796240042739223741879633, 520979008712937962579001, 31472015353079479796110447, 38623718328689304853037278, 17149222936996610613276307, 21988007084256753502814588, 11696872772917077079195865, 6767350497471479755850094]
true_vlist = [a+n*(2**170) for a,n in zip(alist, highbits)]
# redacted_vlist = {f&quot;a{i}&quot;: n &amp;amp; ((2**170)-1) for i, n in enumerate(oracles[:10])}

def create_basis(n,d):
    if d==0:
        return [(set(), R(1))]
    if d==1:
        return [(set([i]), a) for i, a in enumerate(alist)]
    S = list(range(0, n-d+2))
    T = list(range(n-d+2, n))
    # subset of S size d
    basis = []
    for subset in itertools.combinations(S, d):
        A = build_sbg(2*d-2, d, list(subset)+T, alist)
        poly = A.det()
        basis.append((set(subset), poly))
    return basis

all_basis = []
for d in range(7):
    bs = create_basis(10, d)
    all_basis.extend(bs)
monomials = [c[0] for c in all_basis]
# basis_true_value = vector(ZZ, [(c[1])(**redacted_vlist) for c in all_basis])
vec_basis = vector(R, [c[1] for c in all_basis])

assert len(all_basis) == 232

def create_eqs(d):
    assert d &amp;gt;= 2
    n = 10
    S = list(range(0, n-d+2))
    T = list(range(n-d+2, n))
    eqs = []
    for subset in itertools.combinations(S, d):
        poly = build_sbg(2*d-2, d, list(subset)+T, true_vlist).det() - (-1)**d * build_sbg(2*d-2, d-1, list(subset)+T, true_vlist).det()
        # assert poly(**redacted_vlist)%(q**(d-1)) == 0
        eqs.append(poly*(q**(6-d)))
    return eqs

all_eqs = []
for d in range(2, 7):
    all_eqs += create_eqs(d)

def decompose_sbg(poly):
    if poly == 0:
        return [0] * len(monomials)
    
    def find_ids():
        deg = poly.degree()
        for mono in poly.monomials():
            if mono.degree() == deg:
                vs = mono.variables()
                ids = [alist.index(v) for v in vs]
                assert len(ids) == deg
                if set(ids) in monomials:
                    return set(ids)

    ids = find_ids()
    assert ids is not None, poly.monomials()
    for s, p in all_basis:
        if s == ids:
            di, rem = poly.quo_rem(p)
            decomposed = decompose_sbg(rem)
            assert di.monomials() == [1]
            decomposed[monomials.index(ids)] += di
            return decomposed

decomposed_eqs = [vector(ZZ, decompose_sbg(eq)) for eq in all_eqs]
M = matrix(decomposed_eqs)
print(len(decomposed_eqs))

# assert all(eq*basis_true_value%(q**5) == 0 for eq in decomposed_eqs)

scale_d = {
    0: 1,
    1: 2**170,
    2: 2**341,
    3: 2**512,
    4: 2**688,
    5: 2**870,
    6: 2**1054
}
# print([b.bit_length() for b in basis_true_value])
scale = [scale_d[len(m)] for m in monomials]
assert len(scale) == 232

M = block_matrix([[M], [(q**5)*identity_matrix(232)[:11]]])

print(M.nrows(), M.ncols())

for i, s in enumerate(scale):
    M.rescale_col(i, s)

M = flatter(M)
M = M.change_ring(QQ)
for i, s in enumerate(scale):
    M.rescale_col(i, 1/s)
M = M.change_ring(ZZ)

M = M[:230]
# assert M * basis_true_value == vector(ZZ, [0]*230)

bs = M.right_kernel().basis()

print(bs[0][:10])
print(bs[1][:10])

a00 = bs[0][1]
x00 = pow(a00 + highbits[0]*(2**170), -1, q)

print(x00)
print([pow(x00 + i, -1, 2**255-19) &amp;gt;&amp;gt; 170 for i in range(1+3+3+7)])
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item></channel></rss>