1322 words
7 minutes
SekaiCTF 2024 Writeup
2024-08-26
2025-06-10

I played as Twilight Light and solved 3.5 crypto challenges. The crypto is very hard and I can’t fully understand why everything works. So this writeup will mainly focus on how I found these properties heuristically.

はやぶさ#

The challenge is a standard Falcon implementation with only degree 64(too small), which makes it possible to break the lattice using BKZ/flatter.

The verification process of Falcon involves taking a polynomial f(x)f(x) and checking whether the coefficients of f(x)f(x) and f(x)h(x)hashedf(x)h(x)-\mathrm{hashed} are all small. So we can build the lattice like this,

(I64M0pI64)\begin{pmatrix} I_{64}&M\\ 0&pI_{64} \end{pmatrix}

where the ii-th row of MM if the coeficients of xih(x)x^ih(x). After the reduction, we only need to find a cvp to [0]*64+hashed.

hayabusa.sage
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 = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]"
ret = check_output(["flatter"], input=z.encode())
return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", 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("hayabusa.chals.sekai.team", 1337, ssl=True)
io.recvuntil(b"h = ")
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"\x61" * 40
hashed = sk.hash_to_point(b"Can you break me", salt)
v_h = vector([0]*64+hashed)
re = v_h - babai_cvp(B, v_h, perform_reduction=False)
print("cvp:", re)
v0 = vector(GF(q), re[:64])
print(list(re[:64]))
print(mul_zq(list(re[:64]), h))
print(hashed)
fake_sig = b"\x36" + salt + compress(list(re[:64]), 122-41)
if pk.verify(b"Can you break me", fake_sig):
print("well done!!")
break
io.sendline(fake_sig.hex())
io.interactive()

マスタースパーク#

This is a CSIDH challenge with non-standard parameters. It’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.

The fun fact is that isogeny will keep the group structure, so secret*P=Q still holds after the isogeny.

The rest part is straightforward, just get the modulus of secret for many small p and recover it using CRT. For CSIDH, the order of the curve is p+1=4*prod(prime_list), 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’t full determine a point on the curve, and the solution is just finding some big p and enumerate all possible flips.

master-spark.sage
from pwn import *
import os
context.log_level = 'debug'
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() <= 96:
used_primes = used_primes.union(plist)
print(q.bit_length())
return q
io = process(['sage', 'challenge.sage'])
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.<j> = GF(p ^ 2, modulus=x ^ 2 + 1)
io.recvuntil(b'input your prime number or secret > ')
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 >> i) & 1) * r for i, r in enumerate(rs)]
secret = crt(new_rs, ps)
if secret.bit_length() <= 256:
print(secret)
io.sendline(str(secret).encode())
io.interactive()

Squares vs. Cubes#

The 3 hardest challenges are authored by Neobeo. All of them are intersesting and I really like(not solving) them. Here is Neobeo’s writeup.

Description#

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'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'{N = }')
print(f'{x0 = }')
print(f'{x1 = }')
print('\nDo you prefer squares or cubes? Choose wisely!')
# Generate a random k and send v := (x_i + k^e), for Oblivious Transfer
# This will allow you to calculate either Genni's or SBG's value
# I have no way of knowing who you support. Your secret is completely safe!
v = int(input('Send me v: '))
m0 = (pow(v - x0, d, N) + value_for_genni) % N
m1 = (pow(v - x1, d, N) + value_for_sbg) % N
print(f'{m0 = }')
print(f'{m1 = }')

First attempt#

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.

My method is to calculate (p2+C2)3(p3+C3)2=p(...)(p^2+C^2)^3-(p^3+C^3)^2=p(...) and gcd with NN to get pp. But here’s the problem: I can’t eliminate vx0v-x0 and vx1v-x1 when trying to get this expression. The only thing I can control is relations like (v-x0)=-(v-x1) and get the sum of two equations.

But it suggests that pp maybe the first target of the challenge, so instead of modN\mod{N}, we may consider it as modp\mod{p}.

This is a small trick to make the analysis easier. Now these values can be considered univarate with C=q+r×padded_flagC=q+r\times\mathrm{padded\_flag}.

Step 1#

Then I find this blog(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.

If we write this out, it will look like this,

(M1p3C3)Nx0x1(modN)(M_1-p^3-C^3)^N\equiv x0-x1\pmod{N}

However, ee is change to NN, thus Half GCD won’t work.

Luckily, we also have another equation p2+C2M0(modN)p^2+C^2\equiv M_0\pmod{N}, which can be used to do something like pow(M_1-p^3-C^3, N, p^2+C^2-M_0). Remember that our target is to get p, so we are actually running the pow under mod pp, and it looks like

C2M00(modp)(M1C3)x0x1(modp)\begin{align*} C^2-M_0&\equiv 0\pmod{p}\\ (M_1-C^3)&\equiv x0-x1\pmod{p} \end{align*}

So CC is solvable and gcd(N, C^2-M_0) gives us pp.

After extracting pp, we can rerun the power but this time under mod NN with the true M1P3M_1-P^3 value. This time we get C(modN)C\pmod{N}, considering that C21536C\approx2^{1536}, we can throw mod NN and get CC.

So the first step ends with know the value of pp and CC. Now we can’t get more from the OT and we have to solve q,rq,r using C,NC, N.

Step 2#

Then I stucked for 3 hours and didn’t solve it before the end of contest, that’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.

Here’s the code of the first step,

svc.sage
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 = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]"
ret = check_output(["flatter"], input=z.encode())
return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", ret)))
def solve(N, m0, m1, x0, x1):
R.<c> = 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(["python3", "chall.py"])
# io = remote("squares-vs-cubes.chals.sekai.team", 1337, ssl=True)
N = int(io.recvline().strip().split(b" = ")[1])
x0 = int(io.recvline().strip().split(b" = ")[1])
x1 = int(io.recvline().strip().split(b" = ")[1])
io.recvuntil(b"Send me v: ")
io.sendline(str(x0).encode())
m0 = int(io.recvline().strip().split(b" = ")[1])
m1 = int(io.recvline().strip().split(b" = ")[1])
io.close()
return solve(N, m0, m1, x0, x1)

zerodaycrypto#

It will be my favourite crypto of this year. Though I would say it is a reverse engineering after releasing the hint.

assert __import__('re').fullmatch(rb'SEKAI{\w{32}}', flag:=input().encode()) and [pow(int.from_bytes(flag[6:-1], 'big') + i, -1, 2**255-19) >> 170 for i in range(1+3+3+7)] == [29431621415867921698671444, 12257315102018176664717361, 6905311467813097279935853, 13222913226749606936127836, 25445478808277291772285314, 9467767007308649046406595, 33796240042739223741879633, 520979008712937962579001, 31472015353079479796110447, 38623718328689304853037278, 17149222936996610613276307, 21988007084256753502814588, 11696872772917077079195865, 6767350497471479755850094]

Some Basic Analysis#

The challenge gives us high bits of 14 distinct (x+i)1(modp)(x+i)^{-1}\pmod{p} and asks to recover the xx. The equations are as follows,

(x+i)ai10(modp)(x+i)a_i-1\equiv0\pmod{p}

We know the high bits of aia_i, and it will later be replaced from aia_i to Ni+aiN_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.

Recover the Lattice#

Now let’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.

Surprisingly, the paper also claims that SBG10SBG_{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.

The next question is what are the polynomials of the lattice.

Recall that the key of the coppersmith is using product of polynomials to construct a multiple of pkp^k. Following SBG’s idea, it should be sqaure-free, thus here it will be something like,

((x+i)ai1)((x+j)aj1)0(modp2)((x+i)a_i-1)((x+j)a_j-1)\equiv0\pmod{p^2}

and it shouldn’t contain xx, so we need to eliminate xx using 2d22d-2 variables because SBG says that k2d2k\geq 2d-2. For this case, we need 44 variables to get a degree 33 symmetric polynomial that is a multiple of p2p^2.

I wrote a function F to interpolate the desired beetle, and it turns out it’s a sum of (4,3)(4,3) beetle and a (4,2)(4,2) beetle. Dig deeper we can find it is always a sum or subtraction of a (2d2,d)(2d-2,d) beetle and a (2d2,d1)(2d-2,d-1) beetle.

The hint also says that SBG is closed under translation, so it will be fine to replace aia_i to Ni+aiN_i+a_i.

Now we’ve fully understand the detail of the lattice.

Solve Multivarate Polynomial in Real Field#

The next step is solving it in real field.

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.

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×15001500\times1500 lattice. Whatever, just right_kernel gives me the aia_i and that’s solved.

zeroday.sage
from re import fullmatch, findall
import os
import itertools
def flatter(M):
from subprocess import check_output
z = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]"
ret = check_output(["flatter"], input=z.encode())
return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", ret)))
flag = b"SEKAI{" + b"a" * 32 + b"}"
assert fullmatch(rb'SEKAI{\w{32}}', flag)
# print([pow(int.from_bytes(flag[6:-1], 'big') + i, -1, 2**255-19) >> 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), 'big')
# oracles = [pow(oc + i, -1, q) for i in range(20)]
def build_sbg(k, d, i_list, x_list):
assert k>= 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.<x,a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,i,j,k,l> = PolynomialRing(ZZ)
alist = [a0,a1,a2,a3,a4,a5,a6,a7,a8,a9]
# highbits = [n>>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"a{i}": n & ((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 >= 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) >> 170 for i in range(1+3+3+7)])