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 and checking whether the coefficients of and are all small. So we can build the lattice like this,
where the -th row of if the coeficients of . After the reduction, we only need to find a cvp to [0]*64+hashed
.
hayabusa.sage
from falcon import falconfrom falcon.encoding import compress, decompressfrom falcon.ntt import mul_zqfrom flag import flagimport jsonfrom 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 = hsk.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, getPrimefrom secrets import token_bytes, randbelowfrom 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 * rphi = (p - 1) * (q - 1) * (r - 1)d = pow(e, -1, phi)
# Genni likes squares and SBG likes cubes. Let's calculate their valuesvalue_for_genni = p**2 + (q + r * padded_flag)**2value_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) % Nm1 = (pow(v - x1, d, N) + value_for_sbg) % Nprint(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 and gcd with to get . But here’s the problem: I can’t eliminate and 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 maybe the first target of the challenge, so instead of , we may consider it as .
This is a small trick to make the analysis easier. Now these values can be considered univarate with .
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,
However, is change to , thus Half GCD won’t work.
Luckily, we also have another equation , 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 , and it looks like
So is solvable and gcd(N, C^2-M_0)
gives us .
After extracting , we can rerun the power but this time under mod with the true value. This time we get , considering that , we can throw mod and get .
So the first step ends with know the value of and . Now we can’t get more from the OT and we have to solve using .
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, getPrimeimport syssys.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 and asks to recover the . The equations are as follows,
We know the high bits of , and it will later be replaced from to 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 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 . Following SBG’s idea, it should be sqaure-free, thus here it will be something like,
and it shouldn’t contain , so we need to eliminate using variables because SBG says that . For this case, we need variables to get a degree symmetric polynomial that is a multiple of .
I wrote a function F
to interpolate the desired beetle, and it turns out it’s a sum of beetle and a beetle. Dig deeper we can find it is always a sum or subtraction of a beetle and a beetle.
The hint also says that SBG is closed under translation, so it will be fine to replace to .
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 lattice. Whatever, just right_kernel
gives me the and that’s solved.
zeroday.sage
from re import fullmatch, findallimport osimport 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-19oc = 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)])