<?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>R3CTF 2026 Writeup</title><link>https://blog.sceleri.cc/posts/r3ctf-2026-writeup/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/r3ctf-2026-writeup/</guid><pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这周跟 Project SEKAI 一起打了 R3CTF 2026，做了三道 Crypto。&lt;/p&gt;
&lt;h2&gt;teRRibleRing&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Notice that there are 3 &quot;R&quot;s in the title :)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from sage.stats.distributions.discrete_gaussian_integer import DiscreteGaussianDistributionIntegerSampler
from Crypto.Util.number import *
from secrets import randbelow
from secret import flag

p = 0x8000000b
PR.&amp;lt;x&amp;gt; = PolynomialRing(Zmod(p))
f = x^512 + 355853415*x^511 + ...

def F0():
    D = DiscreteGaussianDistributionIntegerSampler(sigma=5.0)
    s = PR([D() for _ in range(512)])
    e = PR([D() for _ in range(512)])
    a = PR([randbelow(p) for _ in range(512)])
    b = (a*s + e) % f
    return [a.list(), b.list()]

def F1():
    a = PR([randbelow(p) for _ in range(512)])
    b = PR([randbelow(p) for _ in range(512)])
    return [a.list(), b.list()]

with open(&quot;samples.txt&quot;, &quot;w&quot;) as s:
    s.write(&quot;output =&quot; + str([F0() if i == &quot;0&quot; else F1() for i in bin(bytes_to_long(flag))[2:].zfill(len(flag)*8)]))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;这道题给了 344 个 degree 512 的 decisional RLWE 样本。其中 $p$ 大约 $2^{31}$，而噪声的 std 只有 5。
显然跑 344 个 1024 维的 LLL 是不现实的，所以只能是 f 有后门。&lt;/p&gt;
&lt;p&gt;假如 $f=g\cdot h$，那么我们可以将 $\mathbb{Z}_p[x]/(f)$ 投影到 $\mathbb{Z}_p[x]/(g)$，从而在更小的维度解决这个 RLWE。
但是这个投影相当于计算 $a\mod g$，而 $g$ 的参数可能非常大，导致噪声也会被放大。因此这个方法只有在 $g$ 的系数足够小的情况下才有用。&lt;/p&gt;
&lt;p&gt;尝试分解 $f$ 可以发现它有一堆小因子，但它们的系数都很大。所以我们先试着猜一些因子乘起来能得到一个系数较小的多项式。假如真的存在这么一个因子，我们可以推测出如下的性质：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;这个因子的度数不能太大，不然 LLL 还是跑不完，这里我们假设度数不超过 128，对应的格维度是 256。&lt;/li&gt;
&lt;li&gt;这个因子的系数不能太大。根据我们前一条假设度数不超过 128，$x^{512}\mod g$ 会差不多把 $g$ 四次方，所以就算是十几的系数都会爆炸，必须得是非常小非常稀疏的才有戏。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;现在我们希望从 $f$ 的 75 个因子里面找出乘积很小的组合。显然枚举是不现实的，但是我们可以这么想：一个因子乘上一个多项式后系数很小的概率是很低的，所以我们可以先猜 $g$ 有哪些因子，再用 LLL 反过来找这个乘上什么多项式后系数很小。这个和 LFSR 找一个稀疏的递推关系是类似的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def find_short_poly(f, max_deg=128):
    coeffs = f.coefficients(sparse=False)
    coeffs = [ZZ(c) for c in coeffs]
    M = []
    for i in range(max_deg - len(coeffs) + 1):
        row = [0] * i + coeffs + [0] * (max_deg - len(coeffs) - i)
        M.append(row)
    M = matrix(ZZ, M)
    M = block_matrix([[M], [identity_matrix(max_deg)*p]])
    M = M.LLL(algorithm=&apos;flatter&apos;)
    return M[0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;考虑到 $p$ 大约 $2^{31}$ 而系数只有个位数，我们可以认为对所有度数大于 12 的因子都不会出现伪阳。于是成功发现一个度数 16 的因子的倍数里面有 $x^{127} - x^{46} + x^{19} - x^8 + 1$。&lt;/p&gt;
&lt;p&gt;接下来就是把 RLWE 投影过来跑 LLL 了。这里直接简单用 Kannan Embedding 跑 cvp 即可。&lt;/p&gt;
&lt;p&gt;$$
\begin{pmatrix}
e &amp;amp; 0 &amp;amp; b \
0 &amp;amp; I &amp;amp; A\
0 &amp;amp; 0 &amp;amp; pI
\end{pmatrix}
$$&lt;/p&gt;
&lt;p&gt;这里 $A$ 是 $a$ 的系数矩阵，然后 $e$ 取一个和噪声差不多大的数。
但是 flatter 没法解出这个 SVP，所以我换到了 BLASter 这个库，支持更多的 BKZ，每个可以 6 秒跑完。
最后花了 20 多分钟跑完 344 个 bit，然后重构出 flag 即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from Crypto.Util.number import *
from tqdm import tqdm

p = 0x8000000b
PR.&amp;lt;x&amp;gt; = PolynomialRing(Zmod(p))
f = x^512 + 355853415*x^511 + ...

R.&amp;lt;x&amp;gt; = PolynomialRing(ZZ)
r = x^127 - x^46 + x^19 - x^8 + 1

rp = r.change_ring(Zmod(p))

def lift_centered(poly):
    return R([c.lift_centered() for c in poly.list()])

def create_ideal_lattice(a, f):
    x = a.parent().gen()
    n = f.degree()
    M = []
    for i in range(n):
        s = (a * x**i) % f
        row = lift_centered(s).list()
        M.append(row)
    return matrix(ZZ, M)

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;sage&quot;, &quot;src/app.py&quot;, &quot;-b42&quot;, &quot;-t1&quot;, &quot;-P2&quot;], input=z.encode(), cwd=&quot;/home/sceleri/BLASter&quot;)
    return matrix(M.nrows(), M.ncols(), map(int, findall(b&quot;-?\\d+&quot;, ret)))

a, b, s, e = F0()

def check(data):
    a, b = data
    a = PR(a)
    b = PR(b)
    ar = a % rp
    br = b % rp
    coef_variance = [0] * 127

    for i in range(512):
        c = x ** i % r
        for j in range(127):
            coef_variance[j] += c[j] ** 2

    coef_variance = [round(sqrt(c * 65536)) for c in coef_variance]

    M = create_ideal_lattice(ar, rp)

    target = matrix(ZZ, [0]*127+lift_centered(br).list())

    n = M.nrows()
    M = block_matrix(ZZ, [[identity_matrix(ZZ, n), M], [zero_matrix(ZZ, n, n), p*identity_matrix(ZZ, n)]])

    M = block_matrix(ZZ, [[M, 0], [target, 512]])

    M = flatter(M)

    v = M[0]
    cnt = 0
    for c in v:
        if abs(c) &amp;lt; 1024:
            cnt += 1
    if cnt &amp;gt; 200:
        return 0
    else:
        return 1

output = []
exec(open(&quot;samples.txt&quot;).read())

c = []

def pack_byte(ulist):
    ret = 0
    for i in range(8):
        ret |= (ulist[i] &amp;lt;&amp;lt; (7-i))
    return ret

def decode_flag(c):
    out = b&quot;&quot;
    for i in range(0, len(c), 8):
        if i + 8 &amp;gt; len(c):
            break
        out += bytes([pack_byte(c[i:i+8])])
    return out

from tqdm.contrib.concurrent import process_map

c = []
for i in tqdm(range(len(output) // 8)):
    u = process_map(check, [output[i*8+j] for j in range(8)])
    c.extend(u)
    print(c)
    print(decode_flag(c))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;rECp1cG&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Quiet steps, old notes:&lt;/p&gt;
&lt;p&gt;https://eprint.iacr.org/2007/099.pdf&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;from hashlib import sha256
from random import SystemRandom
from secrets import token_hex
import signal

from secret import flag

p_bits = 1024
k = 21
d_bits = 451
Delta = 1 &amp;lt;&amp;lt; d_bits
solve_timeout = 888

rand = SystemRandom()

def is_prime(x):
    if x &amp;lt; 2:
        return False
    small = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43,
             47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97)
    for q in small:
        if x == q:
            return True
        if x % q == 0:
            return False

    odd = x - 1
    power = 0
    while odd % 2 == 0:
        odd //= 2
        power += 1

    bases = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43,
             47, 53, 59, 61)
    for base in bases:
        if base % x == 0:
            continue
        y = pow(base, odd, x)
        if y == 1 or y == x - 1:
            continue
        for _ in range(power - 1):
            y = pow(y, 2, x)
            if y == x - 1:
                break
        else:
            return False
    return True

def make_prime(bits):
    while True:
        x = rand.getrandbits(bits)
        x |= 1 &amp;lt;&amp;lt; (bits - 1)
        x |= 3
        if is_prime(x):
            return x

def add(P, Q, a, p):
    if P is None:
        return Q
    if Q is None:
        return P

    x1, y1 = P
    x2, y2 = Q
    if x1 == x2 and (y1 + y2) % p == 0:
        return None

    if P == Q:
        slope = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p
    else:
        slope = (y2 - y1) * pow(x2 - x1, -1, p) % p

    x3 = (slope * slope - x1 - x2) % p
    y3 = (slope * (x1 - x3) - y1) % p
    return x3, y3

def random_point(a, b, p):
    while True:
        x = rand.randrange(1, p)
        y2 = (x * x * x + a * x + b) % p
        if y2 != 0 and pow(y2, (p - 1) // 2, p) == 1:
            y = pow(y2, (p + 1) // 4, p)
            if rand.getrandbits(1):
                y = (-y) % p
            return x, y

def main():
    key_tag = token_hex(16)
    p = make_prime(p_bits)

    while True:
        a = rand.randrange(1, p)
        b = rand.randrange(1, p)
        if (4 * a * a * a + 27 * b * b) % p != 0:
            break

    while True:
        g = random_point(a, b, p)
        u = random_point(a, b, p)
        points = [u]
        for _ in range(k - 1):
            u = add(u, g, a, p)
            if u is None:
                break
            points.append(u)
        if len(points) == k and len({P[0] for P in points}) == k:
            break

    states = []
    for x, _ in points:
        while True:
            shown = x - rand.randrange(-Delta, Delta + 1)
            if 0 &amp;lt;= shown &amp;lt; p:
                states.append(shown)
                break

    size = (p.bit_length() + 7) // 8
    nums = (a, b, g[0], g[1], points[0][0], points[0][1])
    material = b&quot;&quot;.join(int(x).to_bytes(size, &quot;big&quot;) for x in nums)
    key = sha256(material + b&quot;|&quot; + key_tag.encode()).digest()

    pad = b&quot;&quot;
    ctr = 0
    while len(pad) &amp;lt; len(flag):
        pad += sha256(key + ctr.to_bytes(4, &quot;big&quot;)).digest()
        ctr += 1
    ct = bytes(x ^ y for x, y in zip(flag, pad)).hex()

    print(f&quot;p = {p}&quot;)
    print(f&quot;a = {a}&quot;)
    print(f&quot;b = {b}&quot;)
    print(f&quot;Delta = {Delta}&quot;)
    print(f&quot;G = {g}&quot;)
    print(f&quot;states = {states}&quot;)
    print()
    print(&quot;# submit P0.x&quot;, flush=True)

    signal.alarm(solve_timeout)
    try:
        answer = input().strip()
    except EOFError:
        return

    if len(answer) &amp;gt; 4096:
        print(&quot;# wrong&quot;, flush=True)
        return

    try:
        recovered_x = int(answer, 0)
    except ValueError:
        print(&quot;# wrong&quot;, flush=True)
        return

    if recovered_x != points[0][0]:
        print(&quot;# wrong&quot;, flush=True)
        return

    print(&quot;# ok&quot;, flush=True)
    print(f&quot;key_tag = {key_tag!r}&quot;)
    print(f&quot;ct = {ct!r}&quot;, flush=True)

if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;一眼看上去就是论文题，但是如果顺着题目里给的论文找就被阴了。这里我们需要找的是只给 $x_i$ MSB 的 Coppersmith 构造，然后应该搜索的是 ECHNP。在 Google Scholar 看 cite 可以找到 2026 年的最新工作[^1]。
然后就简单了，ocr 成 latex 交给 AI 去抄那一堆多项式构造即可，而且代入测试数据检查 $\mkern{-20mu}\mod p^2$ 是否为 0 就可以验证正确性。&lt;/p&gt;
&lt;p&gt;[^1]: Xu, J., Sarkar, S., Wang, H., &amp;amp; Hu, L. (2026). New Results on Elliptic Curve Hidden Number Problem for ECDH Key Exchange: J. Xu et al. Journal of Cryptology, 39(2), 20.&lt;/p&gt;
&lt;p&gt;实际跑的时候发现对于 451 bits 的 delta，LLL reduce 后的多项式大概会落在 $-3p^2\sim 3p^2$ 的范围内，但是只要不是 0 就没法用 grobner 解。这个 Coppersmith 有 11 个变量，所以直接暴力猜测是不行的（除非你愿意跑 1000 次 408 维的 LLL），所以需要一点小优化就是 coppersmith base polys 里面有很多都包含了 $x$，所以正确做法是在设置 bounds 的时候把 $x$ 的 bound 除以 8，这样就只要猜 8 次即可。&lt;/p&gt;
&lt;p&gt;下面的代码是清理过的，同时把 GPT 写的 grobner 换成了 msolve。[^2]&lt;/p&gt;
&lt;p&gt;[^2]: 但还是用了 500 行，感觉除了没有用 sage 的 EC 之外都没法变短。如果没有 ai 在 ctf 的时候写 500 行是绝对的折磨，只能说感谢 ai。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from sage.all import *
from hashlib import sha256
from itertools import combinations
import argparse
import ast
import re
import shutil
import subprocess
from pwn import context, remote, process

n = 10
d = 2
t = 1
mid = n

def ec_add(P, Q, a, p):
    if P is None:
        return Q
    if Q is None:
        return P
    x1, y1 = P
    x2, y2 = Q
    if x1 == x2 and (y1 + y2) % p == 0:
        return None
    if P == Q:
        lam = (3 * x1 * x1 + a) * inverse_mod(2 * y1, p) % p
    else:
        lam = (y2 - y1) * inverse_mod(x2 - x1, p) % p
    x3 = (lam * lam - x1 - x2) % p
    y3 = (lam * (x1 - x3) - y1) % p
    return (ZZ(x3), ZZ(y3))

def ec_neg(P, p):
    if P is None:
        return None
    return (P[0], (-P[1]) % p)

def ec_mul(k, P, a, p):
    R = None
    Q = P
    k = ZZ(k)
    while k:
        if k &amp;amp; 1:
            R = ec_add(R, Q, a, p)
        Q = ec_add(Q, Q, a, p)
        k &amp;gt;&amp;gt;= 1
    return R

def as_zz_data(data):
    out = dict(data)
    for k in (&quot;p&quot;, &quot;a&quot;, &quot;b&quot;, &quot;Delta&quot;):
        out[k] = ZZ(out[k])
    out[&quot;G&quot;] = (ZZ(out[&quot;G&quot;][0]), ZZ(out[&quot;G&quot;][1]))
    out[&quot;states&quot;] = [ZZ(x) for x in out[&quot;states&quot;]]
    if &quot;points&quot; in out:
        out[&quot;points&quot;] = [(ZZ(P[0]), ZZ(P[1])) for P in out[&quot;points&quot;]]
    if &quot;errors&quot; in out:
        out[&quot;errors&quot;] = [ZZ(e) for e in out[&quot;errors&quot;]]
    return out

def signed_mod(x, m):
    x = ZZ(x) % m
    if x &amp;gt; m // 2:
        x -= m
    return ZZ(x)

class ECXHNPSolver:
    def __init__(self, data, check=False, verbose=True):
        self.data = as_zz_data(data)
        self.p = self.data[&quot;p&quot;]
        self.a = self.data[&quot;a&quot;]
        self.b = self.data[&quot;b&quot;]
        self.Delta = self.data[&quot;Delta&quot;]
        self.G = self.data[&quot;G&quot;]
        self.states = self.data[&quot;states&quot;]
        self.check = False
        self.verbose = verbose
        if len(self.states) &amp;lt; 2*n + 1:
            raise ValueError(&quot;need 21 states for n=10&quot;)

        names_z = [&quot;x&quot;] + [&quot;z%d&quot; % i for i in range(1, n + 1)]
        names_y = [&quot;x&quot;] + [&quot;y%d&quot; % i for i in range(1, n + 1)]
        self.Rz = PolynomialRing(ZZ, names_z, order=&quot;degrevlex&quot;)
        self.Ry = PolynomialRing(ZZ, names_y, order=&quot;degrevlex&quot;)
        self.RQy = PolynomialRing(QQ, names_y, order=&quot;degrevlex&quot;)
        self.xz = self.Rz.gen(0)
        self.zs = [None] + [self.Rz.gen(i) for i in range(1, n + 1)]
        self.xy = self.Ry.gen(0)
        self.ys = [None] + [self.Ry.gen(i) for i in range(1, n + 1)]

        self.Q = [None] + [ec_mul(i, self.G, self.a, self.p) for i in range(1, n + 1)]
        self.h0 = self.states[mid]
        self.A = [None]
        self.B = [None]
        self.C = [None]
        self.D = [None]
        self.H = [None]
        self.F = [None]
        self._L_cache = {}
        self._L0_triples = {}
        self._tilde_cache = {}
        self._over_cache = {}
        self._build_base_polys()

    def log(self, s):
        if self.verbose:
            print(s, flush=True)

    def _build_base_polys(self):
        x = self.xz
        for i in range(1, n + 1):
            xq, yq = self.Q[i]
            tilde_h = self.states[mid + i] + self.states[mid - i]
            Ai = self.h0 - xq
            Bi = -2 * (self.a + 3 * xq**2)
            Ci = Ai * Bi - 4 * yq**2
            Di = tilde_h - 2 * xq
            self.A.append(ZZ(Ai))
            self.B.append(ZZ(Bi))
            self.C.append(ZZ(Ci))
            self.D.append(ZZ(Di))
            self.H.append((x + Ai)**2 * self.zs[i] + Bi*x + Ci)
        for i in range(1, n + 1):
            self.F.append(self.to_y(self.H[i]))

    def _check_base_polys(self):
        for i in range(1, n + 1):
            self.assert_z(self.H[i], self.p, &quot;H_%d&quot; % i)
            self.assert_y(self.F[i], self.p, &quot;F_%d&quot; % i)
        self.log(&quot;[check] H_i/F_i roots mod p: ok&quot;)

    def assert_z(self, poly, mod, label):
        val = ZZ(poly(*self.zroot))
        if val % mod != 0:
            raise AssertionError(&quot;%s(root) mod %s != 0&quot; % (label, mod))

    def assert_y(self, poly, mod, label):
        val = ZZ(poly(*self.yroot))
        if val % mod != 0:
            raise AssertionError(&quot;%s(root) mod %s != 0&quot; % (label, mod))

    def to_y(self, poly):
        args = [self.xy] + [self.ys[i] + self.D[i] for i in range(1, n + 1)]
        return self.Ry(poly(*args))

    def reduce_poly(self, poly, mod, ring):
        terms = {}
        for exp, coeff in poly.dict().items():
            c = ZZ(coeff) % mod
            if c:
                terms[exp] = c
        return ring(terms)

    def H_vector(self, js):
        js = tuple(js)
        vec = []
        x = self.xz
        for m, jm in enumerate(js):
            term = self.zs[jm]
            for r, jr in enumerate(js):
                if r != m:
                    term *= self.H[jr]
            vec.append(term)
        for m, jm in enumerate(js):
            term = x * self.zs[jm]
            for r, jr in enumerate(js):
                if r != m:
                    term *= self.H[jr]
            sub = self.Rz(0)
            for u, ju in enumerate(js):
                if u == m:
                    continue
                part = self.Rz(self.B[ju])
                for r, jr in enumerate(js):
                    if r != u:
                        part *= self.H[jr]
                sub += part
            vec.append(term - sub)
        return vec

    def M_matrix(self, js, mod):
        x = self.xz
        rows = []
        for shift in (0, 1):
            for m, jm in enumerate(js):
                poly = self.Rz(x**shift)
                for r, jr in enumerate(js):
                    if r != m:
                        poly *= (x + self.A[jr])**2
                row = [ZZ(poly.monomial_coefficient(x**k)) % mod for k in range(2*len(js))]
                rows.append(row)
        return Matrix(Zmod(mod), rows)

    def L(self, js, m):
        js = tuple(sorted(js))
        key = (js, m)
        if key in self._L_cache:
            return self._L_cache[key]
        l = len(js)
        mod = self.p ** (l - 1)
        W = self.M_matrix(js, mod).inverse()
        hv = self.H_vector(js)
        poly = self.Rz(0)
        for c, h in zip(list(W[m]), hv):
            poly += ZZ(c) * h
        poly = self.reduce_poly(poly, mod, self.Rz)
        self._L_cache[key] = poly
        if self.check:
            self.assert_z(poly, mod, &quot;L_%d%s&quot; % (m, js))
        return poly

    def coeff_xz(self, poly, xpow, zset):
        exp = [0] * (n + 1)
        exp[0] = xpow
        for j in zset:
            exp[j] = 1
        return ZZ(poly.monomial_coefficient(self.Rz.monomial(*exp)))

    def overline_L0(self, js):
        js = tuple(sorted(js))
        if js in self._over_cache:
            return self._over_cache[js]
        poly = self.L(js, 0)
        for pair in combinations(js, d):
            c = self.coeff_xz(poly, 2*d - 1, pair)
            if c:
                poly -= c * self.L(pair, 2*d - 1)
        poly = self.reduce_poly(poly, self.p, self.Rz)
        self._over_cache[js] = poly
        if self.check:
            self.assert_z(poly, self.p, &quot;overline_L0%s&quot; % (js,))
        return poly

    def ordered_triples(self):
        triples = list(combinations(range(1, n + 1), d + 1))
        triples.sort(key=lambda c: tuple(1 if i in c else 0 for i in range(n, 0, -1)))

        pairs = list(combinations(range(1, n + 1), d))
        rows = []
        for tri in triples:
            L0 = self.L(tri, 0)
            self._L0_triples[tri] = L0
            rows.append([self.coeff_xz(L0, 2*d - 1, pair) % self.p for pair in pairs])

        M = Matrix(GF(self.p), rows).transpose()
        pivots = list(M.pivots())
        if len(pivots) &amp;lt; binomial(n, d):
            raise ValueError(&quot;could not find enough independent triples&quot;)
        pivots = pivots[:binomial(n, d)]
        pivot_set = set(pivots)
        ordered = [triples[i] for i in pivots] + [
            triples[i] for i in range(len(triples)) if i not in pivot_set
        ]
        self.triples = ordered
        self.pairs = pairs

        q = self.p**d
        Arows = []
        for tri in self.triples[:binomial(n, d)]:
            L0 = self._L0_triples[tri]
            Arows.append([self.coeff_xz(L0, 2*d - 1, pair) % q for pair in pairs])
        self.elim_A = Matrix(Zmod(q), Arows)
        _ = self.elim_A.inverse()
        self.log(&quot;[construct] selected %d invertible L0 triples for Construction II&quot; % len(Arows))

    def tilde_L(self, i0, v):
        key = (i0, v)
        if key in self._tilde_cache:
            return self._tilde_cache[key]
        tri = self.triples[v]
        q = self.p**d
        target = vector(Zmod(q), [
            self.coeff_xz(self.L(tri, i0), 2*d - 1, pair) % q
            for pair in self.pairs
        ])
        e = self.elim_A.transpose().solve_right(target)
        poly = self.L(tri, i0)
        for u in range(binomial(n, d)):
            eu = ZZ(e[u])
            if eu:
                poly -= eu * self.L(self.triples[u], 0)
        poly = self.reduce_poly(poly, q, self.Rz)
        self._tilde_cache[key] = poly
        if self.check:
            self.assert_z(poly, q, &quot;tilde_L_%d_%s&quot; % (i0, tri))
        return poly

    def construct_lattice_polys(self):
        self.ordered_triples()
        x = self.xy
        polys = []
        labels = []
        p = self.p

        for i0 in range(0, 2*d - 1):
            polys.append(p**d * x**i0)
            labels.append(&quot;I(%d)&quot; % i0)

        for j in range(1, n + 1):
            for i0 in range(0, 2):
                polys.append(p**d * x**i0 * self.ys[j])
                labels.append(&quot;IIa(%d,%d)&quot; % (i0, j))
            polys.append(p**(d - 1) * self.F[j])
            labels.append(&quot;IIb(2,%d)&quot; % j)

        for pair in combinations(range(1, n + 1), 2):
            for i0 in range(0, 2*d - 1):
                polys.append(p**(d - 2 + 1) * self.to_y(self.L(pair, i0)))
                labels.append(&quot;IIIa(%d,%s)&quot; % (i0, pair))

        cutoff = binomial(n, d)
        for v, tri in enumerate(self.triples):
            if v &amp;lt; cutoff:
                polys.append(p * self.to_y(self.overline_L0(tri)))
                labels.append(&quot;IVa(0,%s)&quot; % (tri,))
            else:
                polys.append(self.to_y(self.tilde_L(0, v)))
                labels.append(&quot;IVb(0,%s)&quot; % (tri,))
        for i0 in range(1, t + 1):
            for v, tri in enumerate(self.triples):
                polys.append(self.to_y(self.tilde_L(i0, v)))
                labels.append(&quot;IVb(%d,%s)&quot; % (i0, tri))

        expected_dim = (t + 1) * binomial(n, d + 1) + (2*d - 1) * sum(
            binomial(n, l) for l in range(d + 1)
        )
        if len(polys) != expected_dim:
            raise AssertionError(&quot;dimension mismatch: got %d, expected %d&quot; % (len(polys), expected_dim))

        if self.check:
            for label, poly in zip(labels, polys):
                self.assert_y(poly, p**d, label)
            self.log(&quot;[check] all %d lattice polynomials vanish mod p^2: ok&quot; % len(polys))
        self.polys = polys
        return polys

    def scale_poly_terms(self, poly, bounds):
        terms = {}
        for exp, coeff in poly.dict().items():
            c = ZZ(coeff)
            for ei, bi in zip(exp, bounds):
                if ei:
                    c *= bi**ei
            if c:
                terms[exp] = c
        return terms

    def build_lattice(self, bounds):
        polys = self.construct_lattice_polys()
        term_rows = [self.scale_poly_terms(f, bounds) for f in polys]
        monoms = sorted(set().union(*[set(r.keys()) for r in term_rows]))
        idx = {m: i for i, m in enumerate(monoms)}
        M = Matrix(ZZ, len(term_rows), len(monoms))
        for r, row in enumerate(term_rows):
            for exp, coeff in row.items():
                M[r, idx[exp]] = coeff
        self.monoms = monoms
        self.bounds = bounds
        self.log(&quot;[lattice] matrix %d x %d&quot; % (M.nrows(), M.ncols()))
        return M

    def vector_to_poly(self, row):
        terms = {}
        for coeff, exp in zip(row, self.monoms):
            coeff = ZZ(coeff)
            if not coeff:
                continue
            scale = ZZ(1)
            for ei, bi in zip(exp, self.bounds):
                if ei:
                    scale *= bi**ei
            if coeff % scale != 0:
                return None
            terms[exp] = coeff // scale
        return self.Ry(terms)

    def reduced_polys(self, bounds, max_rows):
        M = self.build_lattice(bounds)
        self.log(&quot;[LLL] reducing...&quot;)
        B = self.reduce_matrix(M)
        self.log(&quot;[LLL] done&quot;)
        if max_rows is None or max_rows &amp;lt;= 0:
            row_limit = B.nrows()
        else:
            row_limit = min(max_rows, B.nrows()-1)
        out = []
        decoded = 0
        zero_over_zz = 0
        for i in range(row_limit):
            f = self.vector_to_poly(B.row(i))
            if f is None or f == 0:
                continue
            decoded += 1
            if self.check:
                val = ZZ(f(*self.yroot))
                if val == 0:
                    zero_over_zz += 1
                    out.append(f)
            else:
                out.append(f)
        if self.check:
            self.log(
                &quot;[LLL] ZZ root check: %d/%d decoded rows vanish (checked %d/%d rows)&quot;
                % (zero_over_zz, decoded, row_limit, B.nrows())
            )
        self.log(&quot;[LLL] collected %d candidate integer polynomials&quot; % len(out))
        return out

    def reduce_matrix(self, M):
        rows = []
        for i in range(M.nrows()):
            rows.append(&quot;[&quot; + &quot; &quot;.join(str(ZZ(x)) for x in M.row(i)) + &quot;]&quot;)
        inp = &quot;[&quot; + &quot;\n&quot;.join(rows) + &quot;\n]\n&quot;
        proc = subprocess.run(
            [&quot;flatter&quot;],
            input=inp,
            text=True,
            capture_output=True,
            check=True,
            timeout=840,
        )
        parsed = []
        for line in proc.stdout.splitlines():
            line = line.strip()
            if not line or line == &quot;]&quot;:
                continue
            line = line.lstrip(&quot;[&quot;).rstrip(&quot;]&quot;)
            if line:
                parsed.append([ZZ(x) for x in line.split()])
        if len(parsed) == M.nrows():
            self.log(&quot;[LLL] used flatter&quot;)
            return Matrix(ZZ, parsed)

    def solve_root(self, bounds, max_rows):
        short_polys = self.reduced_polys(bounds, max_rows=max_rows)
        if len(short_polys) &amp;lt; n + 1:
            raise ValueError(&quot;not enough short polynomials&quot;)

        ideal = self.RQy.ideal(short_polys)
        self.log(&quot;[root] computing Groebner basis...&quot;)
        import signal
        signal.alarm(120)
        root = ideal.variety(algorithm=&quot;msolve&quot;, proof=False)
        print(root)
        if not root:
            raise ValueError(&quot;variety returned no roots&quot;)
        return ZZ(root[0][self.RQy(&quot;x&quot;)])

    def recover_p0_x(self, e0):
        x_mid = (self.h0 + e0) % self.p
        rhs = (x_mid**3 + self.a*x_mid + self.b) % self.p
        if kronecker(rhs, self.p) != 1:
            raise ValueError(&quot;recovered middle x is not on curve&quot;)
        y_mid = ZZ(power_mod(rhs, (self.p + 1) // 4, self.p))
        candidates = []
        tenG = ec_mul(mid, self.G, self.a, self.p)
        for y in (y_mid, (-y_mid) % self.p):
            Pmid = (x_mid, y)
            P0 = ec_add(Pmid, ec_neg(tenG, self.p), self.a, self.p)
            candidates.append(P0[0])
        near = [x for x in candidates if abs(ZZ(x) - self.states[0]) &amp;lt;= self.Delta]
        if near:
            return ZZ(near[0])
        return ZZ(candidates[0])

def parse_remote_data(blob):
    text = blob.decode(errors=&quot;replace&quot;)

    def grab_int(name):
        m = re.search(r&quot;^%s\s*=\s*([0-9]+)\s*$&quot; % re.escape(name), text, re.M)
        if not m:
            raise ValueError(&quot;could not parse %s&quot; % name)
        return ZZ(m.group(1))

    def grab_literal(name):
        m = re.search(r&quot;^%s\s*=\s*(.+)\s*$&quot; % re.escape(name), text, re.M)
        if not m:
            raise ValueError(&quot;could not parse %s&quot; % name)
        return ast.literal_eval(m.group(1))

    return {
        &quot;p&quot;: grab_int(&quot;p&quot;),
        &quot;a&quot;: grab_int(&quot;a&quot;),
        &quot;b&quot;: grab_int(&quot;b&quot;),
        &quot;Delta&quot;: grab_int(&quot;Delta&quot;),
        &quot;G&quot;: grab_literal(&quot;G&quot;),
        &quot;states&quot;: grab_literal(&quot;states&quot;),
    }

def solve_data(data, max_rows):
    solver = ECXHNPSolver(data, check=False)
    bounds = [solver.Delta//8] + [solver.Delta] * n
    e0 = solver.solve_root(bounds, max_rows=max_rows)
    p0x = solver.recover_p0_x(e0)
    print(&quot;P0.x =&quot;, p0x)
    return p0x

def main():
    context.log_level = &quot;debug&quot;
    io = process([&quot;sage&quot;, &quot;challenge.py&quot;])
    # io = remote(&quot;challenge.ctf2026.r3kapig.com&quot;, 30388)
    blob = io.recvuntil(b&quot;# submit P0.x&quot;)
    data = parse_remote_data(blob)
    print(&quot;[remote] parsed p_bits=%d, states=%d&quot; % (ZZ(data[&quot;p&quot;]).bit_length(), len(data[&quot;states&quot;])))
    p0x = solve_data(data, max_rows=120)
    io.sendline(str(p0x).encode())
    io.recvuntil(b&quot;key_tag = &quot;)
    key_tag = ast.literal_eval(io.recvline().strip().decode())
    io.recvuntil(b&quot;ct = &quot;)
    ct = ast.literal_eval(io.recvline().strip().decode())
    ct = bytes.fromhex(ct)

    a = data[&quot;a&quot;]
    b = data[&quot;b&quot;]
    g = data[&quot;G&quot;]
    p = data[&quot;p&quot;]
    EC = EllipticCurve(GF(p), [a, b])
    p0y = EC.lift_x(p0x).y()

    def try_decrypt(a, b, g, p0x, p0y):
        nonlocal ct, key_tag
        size = (p.bit_length() + 7) // 8
        nums = (a, b, g[0], g[1], p0x, p0y)
        # print(nums)
        material = b&quot;&quot;.join(int(x).to_bytes(size, &quot;big&quot;) for x in nums)
        key = sha256(material + b&quot;|&quot; + key_tag.encode()).digest()
        pad = b&quot;&quot;
        ctr = 0
        while len(pad) &amp;lt; len(ct):
            pad += sha256(key + ctr.to_bytes(4, &quot;big&quot;)).digest()
            ctr += 1
        print(bytes(x ^^ y for x, y in zip(ct, pad)))
    
    try_decrypt(a, b, g, p0x, p0y)
    try_decrypt(a, b, g, p0x, -p0y % p)

if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Encrypted Activation&lt;/h2&gt;
&lt;h3&gt;Description&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;The remote server uses a fixed key. The evaluation keys are provided in the attachments.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
from __future__ import annotations
import base64, json, os, random, sys
import fhe_core as fhe
from secret import FLAG
import signal

S = 4
N_DIGITS = 5
LUT_SIZE = S ** N_DIGITS
SETUP_DIR = os.path.join(os.path.dirname(__file__), &quot;setup&quot;)
ROUNDS = 16

def _timeout(_signum, _frame):
    print(&quot;timeout&quot;)
    sys.stdout.flush()
    raise SystemExit(0)

def extract_radix(v: int, s: int, n: int):
    d = []
    for _ in range(n):
        d.append(v % s)
        v //= s
    return d

def combine_radix(d, s: int):
    v = 0
    for x in reversed(d):
        v = v * s + x
    return v

def write_setup():
    os.makedirs(SETUP_DIR, exist_ok=True)
    print(&quot;[*] generating keys...&quot;)
    sk, bsk, ksk = fhe.keygen()
    
    client_blob = fhe.serialize_client_key(sk)
    with open(os.path.join(SETUP_DIR, &quot;client.bin&quot;), &quot;wb&quot;) as f:
        f.write(client_blob)
    print(f&quot;[*] client.bin: {len(client_blob)/1e6:.1f} MB&quot;)

    bsk_blob = fhe.serialize_bsk(bsk)
    with open(os.path.join(SETUP_DIR, &quot;bsk.bin&quot;), &quot;wb&quot;) as f:
        f.write(bsk_blob)
    print(f&quot;[*] bsk.bin: {len(bsk_blob)/1e6:.1f} MB&quot;)

    ksk_blob = fhe.serialize_ksk(ksk)
    with open(os.path.join(SETUP_DIR, &quot;ksk.bin&quot;), &quot;wb&quot;) as f:
        f.write(ksk_blob)
    print(f&quot;[*] ksk.bin: {len(ksk_blob)/1e6:.1f} MB&quot;)
    print(&quot;[*] setup complete -&amp;gt;&quot;, SETUP_DIR)
    return

def load_setup():
    client_path = os.path.join(SETUP_DIR, &quot;client.bin&quot;)
    if not (os.path.exists(client_path)):
        write_setup()
    with open(client_path, &quot;rb&quot;) as f:
        client_blob = f.read()
    sk = fhe.parse_client_key(client_blob)
    return sk

def main() -&amp;gt; int:
    signal.signal(signal.SIGALRM, _timeout)
    signal.alarm(120)
    sk = load_setup()
    lut = [int(token) for token in open(&quot;lut&quot;, &quot;r&quot;).read().split()]
    if len(lut) != LUT_SIZE:
        raise ValueError(f&quot;expected {LUT_SIZE} LUT entries, got {len(lut)}&quot;)
    print(&quot;=== Encrypted Activation-Layer Inference ===&quot;)
    print(&apos;&apos;&apos;Each ciphertext carries one base-%d activation symbol (2 bits). 
Evaluate the published %d-bit activation table on the encrypted 
input using the provided keys, and return the encrypted output 
symbols.&apos;&apos;&apos; % (S, (S.bit_length()-1) * N_DIGITS))
    sys.stdout.flush()

    for idx in range(ROUNDS):
        x = fhe.rng.randrange(LUT_SIZE)
        in_digits = extract_radix(x, S, N_DIGITS)
        seeded_inputs = [fhe.encrypt_ciphertext(sk, d) for d in in_digits]
        payload = { 
            &quot;round&quot;: idx + 1,
            &quot;ciphertext&quot;: [(int.from_bytes(seed, &apos;big&apos;), ct.b) for seed, ct in seeded_inputs],
        }
        print(json.dumps(payload))
        sys.stdout.flush()

        line = sys.stdin.readline()
        if not line:
            return 1
        line = line.strip()
        parts = [t for t in line.replace(&quot;,&quot;, &quot; &quot;).split() if t]
        if len(parts) != N_DIGITS:
            print(&quot;wrong&quot;); sys.stdout.flush(); return 0

        try:
            out_cts = [fhe.parse_lwe_ciphertext(base64.b64decode(t, validate=True)) for t in parts]
        except Exception:
            print(&quot;wrong&quot;); sys.stdout.flush(); return 0

        bad = False
        for c in out_cts:
            if len(c.a) != fhe.N:
                bad = True; break
            zero_cnt = sum(1 for v in c.a if v == 0)
            if zero_cnt &amp;gt; 16:        
                bad = True; break
            if len(set(c.a)) == 1:   
                bad = True; break
        if bad:
            print(&quot;wrong&quot;); sys.stdout.flush(); return 0

        out_digits = [fhe.decrypt_ciphertext(sk, c) for c in out_cts]
        if any(d &amp;gt;= S for d in out_digits):
            print(&quot;wrong&quot;); sys.stdout.flush(); return 0

        y = combine_radix(out_digits, S)
        if y != lut[x]:
            print(&quot;wrong&quot;); sys.stdout.flush(); return 0

    print(FLAG)
    sys.stdout.flush()
    return 0

if __name__ == &quot;__main__&quot;:
    raise SystemExit(main())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Solution&lt;/h3&gt;
&lt;p&gt;:::warning
这道题 95% 是 ai 做的，而且我不懂 TFHE，所以以下内容可能是错的，就当看个乐子。
:::&lt;/p&gt;
&lt;p&gt;TFHE 但是只给了加密解密，让 ai 先分析了 bsk 和 ksk，结论是和 TFHE 论文里的不同，任何非线性的计算只能使用 programmable bootstrapping 来实现。
TFHE 原论文只给了 $2^{32}$ 的实现，但这道题是 $2^{64}$，所以还得自己写。
于是我就先让 ai 实现了一个 programmable bootstrapping 的实现，然后让 ai 优化了一下速度。
比如让 ai 把 TFHE 原论文用的 nayuki 和 fftw 都试着接进来，但是 64 bits 的用 double fft 精度问题太大了。
总之 fft 也没带来多少提升，最终大约 250 ms 一次 bootstrap。&lt;/p&gt;
&lt;p&gt;然后是处理这个 LUT 的问题。
每次 bootstrap 只能使用 mod 5 的 LUT 变换。
从信息论的角度来说，这么干得 1000 次以上才能完成 1024 的 LUT，但题目设置了平均每个 7.5 秒的时限，所以加上多线程也就最多几百次的 bootstrap。&lt;/p&gt;
&lt;p&gt;然后 ai 就开始发力了，说 TFHE 是把信息放在高位的（这个看解密就可以看出来），但是 bootstrap 的算法其实对这个 mod 5 没有感知，所以可以变成 mod 1024 的 ct 之后再 LUT（我内心：啊？），但很可惜的是测试炸了，原因是噪声太大了和 negacyclic 的问题。&lt;/p&gt;
&lt;p&gt;但是换成 mod 32 之后成功率就还可以。于是就把 1024 的 LUT 分成 32 个 mod 32 的 LUT，最后再用 mask 合并成 5 个 mod 5 的输出。
这个 mask 的实现也很神秘，这个 fhe 没有乘法，所以实际是把 mask 和 LUT 得到的 5 个 2 bit 数分别加了起来，相当于一个 8 slot 的 torus 再进行 bootstrap LUT。&lt;/p&gt;
&lt;p&gt;最后大概 400 多次 bootstrap 就可以完成，但是每次的成功率大概是 70%，然后找了一台 128 核的服务器碰运气就过了。&lt;/p&gt;
</content:encoded></item><item><title>Solving 64 bits ECDLP with GPU Acceleration</title><link>https://blog.sceleri.cc/posts/hpc-ecdlp-gpu/</link><guid isPermaLink="true">https://blog.sceleri.cc/posts/hpc-ecdlp-gpu/</guid><pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最初有这个想法是因为 seccon 2024 quals 里面的 xiyi 有一个 unintended solution，分析到最后发现需要 5 分钟跑 32 个 66 bits 的 DLP，然后就拼尽全力无法战胜了。
加之之前每次做 ECDLP 都是用 sage 的或者现场写一个 bsgs，但这样到 48 bits 就开始慢得不行了，还容易炸内存。于是乎想着能不能用 GPU 来加速一下。&lt;/p&gt;
&lt;p&gt;在折磨了一阵子之后，成功搞出来了一个能在半分钟内解决 64 bits ECDLP 的 GPU 程序。
这里面也涉及到了很多 CTF 比赛里不会出现但是现实里很有用的密码学知识和 HPC 技术，因此写本文记录一下。[^0]&lt;/p&gt;
&lt;p&gt;[^0]: 但是写博客咕了半年(x&lt;/p&gt;
&lt;h2&gt;Multi-Precision Integer on GPU&lt;/h2&gt;
&lt;p&gt;首先我们需要一个 GPU 上的高精度整数库。在 CUDA 上跑这个的需求并不多（甚至在 AI 时代整数单元都被砍废了），所以可供我们选择的库非常有限。
这里我选择了 &lt;a href=&quot;https://github.com/NVlabs/CGBN&quot;&gt;CGBN&lt;/a&gt;。
这是 NVIDIA 官方的实现，做的优化也比较好，而且比较新有30系的支持。
缺点是，这个库只支持全局固定的 bit size，而且一个整数至少要在 4 个线程上跑，在 256-bit 的时候 sync 开销有点大。不过对于 ECDLP 来说已经够用了。&lt;/p&gt;
&lt;p&gt;因为 CUDA 的整数运算单元都是 32 位的，所以在 GPU 上高精度整数是切分成 32 位的 limb 来存储和运算的。
CGBN 的一个整数会被分成连续的 TPI 段，同时由 TPI 个线程存储和运算。
大部分的高精度整数运算都已经被 CGBN 实现了，这里简单介绍提一下里面有意思的细节。&lt;/p&gt;
&lt;h3&gt;Addition and Multiplication&lt;/h3&gt;
&lt;p&gt;对密码学量级的整数，朴素的加法和乘法是最快的。&lt;/p&gt;
&lt;p&gt;单线程加法只需要从最低的一个 limb 开始相加，并把进位传递到更高的 limb 即可。CUDA 已经提供了 add with carry 的原语，可以实现高效的多线程加法。
但 CGBN 是多线程的，如果要按照单线程的思路来实现加法，每个线程都需要等待前一个线程的进位传递过来，才能继续计算，这样就被串行化了。
因此，我们需要利用加法是 $\text{AC}^0$ 的性质，通过 CUDA 的 warp sync 实现一次性的同步，从而避免串行化。&lt;/p&gt;
&lt;p&gt;乘法就只是一个朴素的 $O(n^2)$ 乘法实现，多线程实现基本无法避免大量的同步开销。只能从最低位开始每次 sync 一个 limb 算乘法和进位最后累加。&lt;/p&gt;
&lt;h3&gt;Montgomery Multiplication&lt;/h3&gt;
&lt;p&gt;对于椭圆曲线计算来说，真正的瓶颈在于模运算。除法和模运算涉及到大量的分支和循环，对 GPU 非常不友好。如果每次乘法都要做模运算，速度会非常慢。[^2]
幸运的是，我们可以采用 Montgomery 乘法，它能在无需昂贵除法的情况下高效完成模乘，非常适合 GPU 无分支实现。
用 Montgomery 乘法计算 $a \cdot b\pmod{N}$ 可以分成三个步骤：&lt;/p&gt;
&lt;p&gt;[^2]: 这对那些 secp256k1 的 GPU 库来说就不是问题，因为 $p=2^{256}-2^{32}-977$ 的性质很好，可以把高位的值快速降到低位。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;任意选一个 $R$，先计算 $(aR\mod{N})$ 和 $(bR\mod{N})$。&lt;/li&gt;
&lt;li&gt;计算 $(aR\mod{N})(bR\mod{N})$，然后乘上 $R^{-1}\pmod{N}$，得到 $abR\mod{N}$。&lt;/li&gt;
&lt;li&gt;将结果乘上 $R^{-1}\pmod{N}$，将结果还原成 $ab\mod{N}$。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这个过程并没有真正地解决取模的问题，第二步和第三步乘 $R^{-1}\pmod{N}$ 之后仍然需要取模把结果限制到 $N$ 的范围内。
Montgomery 乘法的关键在于提出了 REDC 算法，在 $T&amp;lt;N^2$ 的情况下，可以高效地计算出 $TR^{-1}\pmod{N}$。&lt;/p&gt;
&lt;p&gt;REDC 的具体算法如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;选择一个 $R&amp;gt;N$，且 $R$ 与 $N$ 互质。预计算 $N&apos;= -N^{-1}\pmod{R}$。&lt;/li&gt;
&lt;li&gt;计算 $m = (T \bmod R) \cdot N&apos; \bmod R$。&lt;/li&gt;
&lt;li&gt;计算 $t = (T + mN) / R$。&lt;/li&gt;
&lt;li&gt;如果 $t\geq N$，则返回 $t - N$，否则返回 $t$。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这个算法做了两件事：先加上 $N$ 的倍数使其能被 $R$ 整除，然后除以 $R$ 把值缩小到接近 $N$ 的范围内。
我们可以证明 $t=\frac{T + mN}{R}\leq \frac{N^2 + RN}{R}&amp;lt; 2N$，所以最多减一次 $N$ 就可以把结果限制到 $N$ 内。
这样模 $N$ 的计算就被转化成了 $R$ 的取模和除法。这样看起来好像并没有什么意义，但当我们选择 $R$ 为 $2$ 的幂，就会发现 $R$ 的取模和除法可以通过位运算实现。
同样，第一步把 $a$ 转换成 $aR\mod{N}$ 也可以乘上 $R^2\mod{N}$ 后 REDC 实现。&lt;/p&gt;
&lt;p&gt;因此，我们之后的计算都会以 $aR\mod{N}$ 的形式存储。[^3]&lt;/p&gt;
&lt;p&gt;[^3]: Montgomery 算法在硬件设计和高精度整数库中也被广泛使用，对现代密码学的实际应用有着非常重要的意义。
顺带一提，其发明者 Peter L. Montgomery 还有两个非常有名的算法是 Montgomery Ladder 和 Block Lanczos。&lt;/p&gt;
&lt;h3&gt;Faster Modular Inverse&lt;/h3&gt;
&lt;p&gt;如果我们想解决的是 DLP，那只用 Montgomery Multiplication 就已经足够了。而对 ECDLP 来说，一个重要的问题是点的加法运算中需要计算模逆。&lt;/p&gt;
&lt;p&gt;对于 CUDA，每 32 个线程被称为一个 warp，这 32 个线程会同时执行同一条指令。如果在执行过程中有某些线程需要执行不同的指令（比如分支），就会导致 warp 内部的线程被串行化，极大地降低性能。
像我们常用的 xgcd 算法，每次迭代都需要比较两个数的大小来决定下一步的操作，这就会引入大量的分支，而且每次比较大小和加减法都需要同步所有线程，性能就会非常糟糕。&lt;/p&gt;
&lt;p&gt;这个问题对测信道敏感的场景也很麻烦，比如&lt;a href=&quot;https://github.com/bitcoin-core/secp256k1/blob/master/doc/safegcd_implementation.md&quot;&gt;比特币的椭圆曲线运算&lt;/a&gt;。
这里他们使用了一个非常魔法的算法，在不比较大小的情况下，只通过最低位的奇偶性就可以决定下一步的操作。具体如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def gcd(f, g):
    &quot;&quot;&quot;Compute the GCD of an odd integer f and another integer g.&quot;&quot;&quot;
    assert f &amp;amp; 1  # require f to be odd
    delta = 1     # additional state variable
    while g != 0:
        assert f &amp;amp; 1  # f will be odd in every iteration
        if delta &amp;gt; 0 and g &amp;amp; 1:
            delta, f, g = 1 - delta, g, (g - f) // 2
        elif g &amp;amp; 1:
            delta, f, g = 1 + delta, f, (g + f) // 2
        else:
            delta, f, g = 1 + delta, f, (g    ) // 2
    return abs(f)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大概思路是每次迭代就算 $g, f$ 符号不对，除2也可以确保两个数的绝对值不会增加。
然后在这个 delta 的神秘操作下，可以保证我们有较高的概率能让 $g,f$ 符号恰好能让值变小 2 倍，于是就能在 $O(\log{n})$ 的迭代内变成 1。
具体证明可以看看原论文和这个 &lt;a href=&quot;https://github.com/sipa/safegcd-bounds&quot;&gt;repo&lt;/a&gt;，大概就是依靠凸包控制范围，总之很神秘。
而且因为算法只关注最低位的奇偶性，所以甚至可以很方便地预测每次迭代的操作，从而用乘法实现批量迭代。&lt;/p&gt;
&lt;p&gt;最终在一堆魔法的加持下，这个算法在实现简单的同时性能也非常好，具体可以看 libsecp256k1 里面那些人的讨论。
不过也有一个代价是这里的两个数是有符号的，而 CGBN 不支持有符号整数的运算（加减法和无符号整数等价，不过乘法就不行了），所以我们还是得稍微修改一下库。
最后也是比 CGBN 给的 sample 快了 3 倍。&lt;/p&gt;
&lt;h2&gt;Pollard&apos;s Kangaroo Algorithm&lt;/h2&gt;
&lt;p&gt;下一个问题是传统的 BSGS 算法在 64 bits 往上的时候内存消耗就非常大了，所以这里需要一个更节省内存的算法。&lt;/p&gt;
&lt;p&gt;Pollard&apos;s Kangaroo Algorithm 的大概思路是让若干袋鼠在曲线上进行确定性的随机游走，比如有一个 6 bits 的 jump table，通过当前点的最后 6 bits 确定下一步加上 jump table 里对应的点。
当 jump table 里面的 &lt;code&gt;offset~=O(sqrt(n))&lt;/code&gt; 的时候，袋鼠碰撞的概率会变成大约 $\frac{1}{\sqrt{n}}$。
因为碰撞后两只袋鼠将会走同样的路径，所以我们不需要储存所有的点，只需要储存最后 k bit 都为 0 的点就可以拦截这两只袋鼠。
关于 Pollard&apos;s Kangaroo Algorithm 的介绍和实现可以参考 &lt;a href=&quot;https://github.com/mikorist/Kangaroo-256-bit-python/tree/main&quot;&gt;Kangaroo-256-bit-python&lt;/a&gt; 和 &lt;a href=&quot;https://github.com/JeanLucPons/Kangaroo/tree/master&quot;&gt;Kangaroo&lt;/a&gt;。
不过他们是给 secp256k1 设计的，所以做了很多特殊优化，没法直接拿来用。&lt;/p&gt;
&lt;h3&gt;Batched Inverse&lt;/h3&gt;
&lt;p&gt;对 ECDLP，就算我们用了我们的快速求逆，一次求逆还是比乘法慢了 60 倍左右。[^4]
于是一个神奇的办法是把很多个数的求逆合并成一个批量求逆。也就是&lt;/p&gt;
&lt;p&gt;$$
M = \prod_{i=1}^n a_i\bmod{p}, \qquad
a_i^{-1} = M^{-1}\prod_{j\neq i} a_j\bmod{p}
$$&lt;/p&gt;
&lt;p&gt;[^4]: 在我的 3070 Ti 上测得，乘法 + Montgomery reduction 大概是 6B op/s，而求逆大概是 100M op/s。&lt;/p&gt;
&lt;p&gt;具体实现的时候可以先存储所有的累乘，然后求出 $M^{-1}$，最后再通过乘法得到每个 $a_i^{-1}$。这样一共需要一次求逆和 $3n$ 次乘法。不过由于 GPU 的缓存和袋鼠个数的限制，批量求逆的数量不能太大。
但不论如何 ECDLP 可以轻松用上 100k+ 袋鼠，可以说非常享受了。
最终实现在这里 &lt;a href=&quot;https://github.com/unprintable123/CGBN/tree/master/samples&quot;&gt;CGBN&lt;/a&gt;，不过没怎么优化过缓存，特别是 ECDLP 的超大 batch inverse。&lt;/p&gt;
&lt;h2&gt;Final&lt;/h2&gt;
&lt;p&gt;在绕了这么大一个圈子后，我们可以狠狠地拿 GPU 来非预期炸掉 seccon 2024 quals 的 xiyi 了。
这道题的问题是 $g\equiv-\frac{1}{2}\pmod{p}$，所以我们可以寻找一个 $p$ 使得 $g$ 的阶非常小。这里唯一的办法是找 $2^k+1$ 的素因子中恰为 518 bits 的素数。
经过一番 factordb 搜索，只能找到 $2^{1308}+1$ 和 $2^{2490}+1$，在一堆精细优化后，最后需要解一个 61 bits 和 66 bits 的 DLP。然后就是 GPU 暴力了。&lt;/p&gt;
&lt;p&gt;最终在 3070 Ti 上做到了大约 4 分钟解出 32 个 66 bits 的 DLP，成功拿到 flag。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from subprocess import check_output
from sage.all import *
from sage.groups.generic import bsgs
from tqdm import tqdm
from pwn import remote, process

def run_kangaroo(target, g, order, bound=None, num_retry=3):
    # solve target = g**k mod p
    if bound is None:
        bound = order
    assert order % 2 == 1
    p = g.parent().characteristic()
    inp = f&quot;{p} {g} {order} {target} {bound}\n&quot;
    print(f&quot;Running kangaroo with {bound.bit_length()} bits...&quot;)
    print(inp)
    ret = None
    for _ in range(num_retry):
        try:
            ret = check_output(&quot;./kangaroo_gpu&quot;, input=inp.encode(), shell=True).decode()
            assert &quot;Solution:&quot; in ret
            break
        except Exception as e:
            print(e)
            continue
    assert ret is not None, &quot;Kangaroo failed&quot;
    print(ret)
    k = ret.split(&quot;Solution:&quot;)[1]
    k = k.split(&quot;\n&quot;)[0]
    return ZZ(k)

def dlp_generic(target, g, order, max_bound, num_retry=3):
    if max_bound.bit_length() &amp;gt; 32:
        return run_kangaroo(target, g, order, max_bound, num_retry)
    else:
        return bsgs(g, target, bounds=(ZZ(0), max_bound))

def dlp_prime(target, g, order, num_retry=3):
    if order.bit_length() &amp;gt; 34:
        return run_kangaroo(target, g, order, num_retry=num_retry)
    else:
        os = matrix(ZZ, [[order, 1]])
        return pari(target).znlog(pari(g), pari([order, os])).sage()

def dlp_primepower(target, g, prime, n, num_retry=3):
    order = prime**n
    k = 0
    g0 = g ** (order // prime)
    for i in range(n):
        t0 = target * (g**(-k))
        t0 = t0 ** (prime**(n-1-i))
        assert t0 ** prime == 1
        k += dlp_prime(t0, g0, prime, num_retry) * prime**i
    return k

def dlp_main(target, g, order, max_bound, factors, num_retry=3):
    # solve target = g**k mod p
    cur_bound = max_bound
    mods = []
    for prime, n in factors:
        if cur_bound &amp;lt;= 10:
            break
        if prime &amp;gt; cur_bound: # too large, fall back to generic
            break
        mod = prime**n
        ord_ = order // mod
        g0 = g ** ord_
        t0 = target ** ord_
        cur_bound = cur_bound // mod
        k_ = dlp_primepower(t0, g0, prime, n, num_retry)
        mods.append((k_, mod))
    rs, ms = zip(*mods)
    k0 = crt(list(rs), list(ms))
    MOD = prod(ms) # k = k0 + ? * MOD
    g1 = g ** MOD
    t1 = target * (g ** (-k0))
    k1 = dlp_generic(t1, g1, order // MOD, cur_bound+1, num_retry)
    return k0 + k1 * MOD

def dlp(target, g, order=None, bounds=None, known_factors=None, num_retry=3):
    &quot;&quot;&quot;
    Solve DLP for target = g**k mod p
    
    - **order**: order of g. A multiple of the order (e.g. p-1) is also accepted.
    - **bounds**: The bound for k. If None, it will be set to `order-1`. 
                 If integer, search range is [0, bound]. 
                 If list/tuple, represents an interval [min, max].
    - **known_factors**: List of known factors of order. The rest part of the order will be handled by generic method.
                        Either (prime, exponent) pairs or prime lists are accepted.
    - **num_retry**: Number of retries when kangaroo fails.
    &quot;&quot;&quot;
    assert g.parent().is_prime_field()
    p = g.parent().characteristic()
    if order is None:
        order = p-1
    else:
        order = ZZ(order)
    if bounds is None:
        bounds = order
    if known_factors is None:
        known_factors = list(factor(order, algorithm=&quot;ecm&quot;))
    if isinstance(known_factors[0], (list, tuple)):
        known_primes = [p for p, _ in known_factors]
    else:
        known_primes = known_factors
    if 2 not in known_primes:
        known_primes.append(2)
    assert g ** order == 1
    known_primes.sort()
    for p in known_primes:
        while order % p == 0 and (g ** (order // p)) == 1:
            order = order // p
    assert g ** order == 1
    factors = []
    ord_ = order
    for p in known_primes:
        if ord_ % p != 0:
            continue
        v = ord_.valuation(p)
        factors.append((p, v))
        ord_ = ord_ // (p**v)
    if ord_ &amp;gt; 1:
        factors.append((ord_, 1))
    factors.sort()
    print(factors)
    if bounds is None:
        bounds = (0, order-1)
    elif isinstance(bounds, (list, tuple)):
        bounds = tuple(bounds)
    else:
        bounds = (0, int(bounds))
    min_bound, max_bound = bounds
    t2 = target * (g ** (-min_bound))
    range = max_bound - min_bound
    return min_bound + dlp_main(t2, g, order, range, factors, num_retry), factors

import json
from secrets import randbelow

from lib2 import Cryptosystem, Privkey, Pubkey, Ct # same as lib.py
from params import L, M, N

p = 470772889347891753894848105243789217710301326704247389714521154467591283889037042443973128895041119713324021962506767036488831747747963841111160849037254561 # (2^1308+1)
q = 731433453306625773851831070026391128383727479375152752977676902094814479855682993608187229204658713734108641142148010876470120999557683935590321341245075861 # (2^2490+1)

p1_factors = [(2, 5), (3, 3), (5, 1), (109, 1), (457456471313, 1), (53400322888084232503, 1), (4952131943166985961003171, 1), (12483277974290346429122493663434783301283, 1), (662043754266558743926932151479961636112025140409089081, 1)]
q1_factors = [(2, 2), (3, 1), (5, 1), (61, 1), (83, 1), (2099, 1), (14071, 1), (361728968932127, 1), (225369400669558848086892569194138533812747585329213772218859604310602106229150875171288346031013036088614600109065387761801873239, 1)]
n_factors = list(sorted(set([pf[0] for pf in p1_factors] + [qf[0] for qf in q1_factors])))+[p]
p_used = [2, 3, 457456471313, 53400322888084232503]
q_used = [61, 2099, 14071, 361728968932127]
p_unused = prod([4952131943166985961003171, 12483277974290346429122493663434783301283, 662043754266558743926932151479961636112025140409089081])
q_unused = 225369400669558848086892569194138533812747585329213772218859604310602106229150875171288346031013036088614600109065387761801873239

n = p**2 * q
order = p*lcm(p-1,q-1)

for x in range(2, 10000):
    x = mod(x, n)
    assert x ** order == 1
    good = True
    for p0 in n_factors:
        if (x ** (order // p0)) == 1:
            good = False
            break
    if good:
        gx = int(x)
        break

bad_g = mod(n // 2, p*q)
bad_g_order = 1085640
assert bad_g ** bad_g_order == 1

priv_key = Privkey(p, q, Pubkey(n))

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

# initialize
C = Cryptosystem.from_privkey(priv_key)
assert C.privkey is not None
enc_xs = [Ct(pow(gx, 281**i, n)) for i in range(L)]

# 1: (client) --- n, enc_xs ---&amp;gt; (server)
io.sendlineafter(b&quot;&amp;gt; &quot;, json.dumps({&quot;n&quot;: n, &quot;enc_xs&quot;: enc_xs}).encode())

def decrypt_correct(c: int) -&amp;gt; int:
    a = C.L(pow(c, p - 1, p**2))
    b = C.L(pow(gx, p - 1, p**2))
    return a * pow(b, -1, p) % p

# 3: (server) --- enc_alphas, beta_sum_mod_n ---&amp;gt; (client)
params = json.loads(io.recvline().strip().decode())
enc_alphas, beta_sum_mod_n = params[&quot;enc_alphas&quot;], params[&quot;beta_sum_mod_n&quot;]
beta_sum_mod_n = decrypt_correct(n//2) * beta_sum_mod_n % p
alphas = [decrypt_correct(enc_alpha) for enc_alpha in enc_alphas]
alpha_sum = sum(alphas) % p
inner_product = (alpha_sum + beta_sum_mod_n) % p
print(f&quot;{inner_product = }&quot;)

ys = []
for i in tqdm(range(L)):
    target = pow(enc_alphas[i], bad_g_order, p*q)
    base = pow(gx, 281**i, n)
    base = pow(base, bad_g_order, p*q)
    # base ** y_i == target
    target2 = pow(target, p_unused * q_unused, p*q)
    base2 = pow(base, p_unused * q_unused, p*q)
    
    p_i, f_p = dlp(mod(target2, p), mod(base2, p), (p-1)//p_unused, known_factors=p1_factors)
    q_i, f_q = dlp(mod(target2, q), mod(base2, q), (q-1)//q_unused, known_factors=q1_factors)
    # print(p_i, f_p)
    # print(q_i, f_q)
    mod1 = prod([pf[0]**pf[1] for pf in f_p])
    mod2 = prod([qf[0]**qf[1] for qf in f_q])
    ci = (inner_product - sum([ys[j] * 281**j for j in range(i)]))
    assert ci % (281**i) == 0
    inner_product_i = ci // (281**i)
    y_i_0 = crt([p_i, q_i, inner_product_i%281], [mod1, mod2, 281])
    MOD = mod1 * mod2 * 281
    base3 = mod(base, p) ** ((p-1)//p_unused)
    target3 = mod(target, p) ** ((p-1)//p_unused)
    target3 = target3 / (base3 ** y_i_0)
    base3 = base3 ** MOD
    y_i_1 = dlp_generic(target3, base3, p_unused, M//MOD)
    y_i = y_i_0 + y_i_1 * MOD
    print(f&quot;{i = }, {y_i = }&quot;)
    ys.append(int(y_i))




# If, by any chance, you can guess ys, send it for the flag!
io.sendlineafter(b&quot;&amp;gt; &quot;, json.dumps({&quot;ys&quot;: ys, &quot;p&quot;: C.privkey.p, &quot;q&quot;: C.privkey.q}).encode())
print(io.recvline().strip().decode())  # Congratz! or Wrong...
print(io.recvline().strip().decode())  # flag or ys
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><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>