I played in SekaiCTF 2022 with FrenchRoomba and we finished in 2nd place! Our team solved all of the crypto challenges which were all quite interesting. Thanks for the fun and well organised CTF :)
EZmaze
11 solves, 494 points
Can you escape the Maze? OwO
Author: Utaha
nc challs.ctf.sekai.team 3005
chall.py
:
#!/usr/bin/env python3
import os
import random
from Crypto.Util.number import *
from flag import flag
directions = "LRUD"
SOLUTION_LEN = 64
def toPath(x: int):
s = bin(x)[2:]
if len(s) % 2 == 1:
s = "0" + s
path = ""
for i in range(0, len(s), 2):
path += directions[int(s[i:i+2], 2)]
return path
def toInt(p: str):
ret = 0
for d in p:
ret = ret * 4 + directions.index(d)
return ret
def final_position(path: str):
return (path.count("R") - path.count("L"), path.count("U") - path.count("D"))
class RSA():
def __init__(self):
self.p = getPrime(512)
self.q = getPrime(512)
self.n = self.p * self.q
self.phi = (self.p - 1) * (self.q - 1)
self.e = 65537
self.d = pow(self.e, -1, self.phi)
def encrypt(self, m: int):
return pow(m, self.e, self.n)
def decrypt(self, c: int):
return pow(c, self.d, self.n)
def main():
solution = "".join([random.choice(directions) for _ in range(SOLUTION_LEN)])
sol_int = toInt(solution)
print("I have the solution of the maze, but I'm not gonna tell you OwO.")
rsa = RSA()
print(f"n = {rsa.n}")
print(f"e = {rsa.e}")
print(hex(rsa.encrypt(sol_int)))
while True:
try:
opt = int(input("Options: 1. Decrypt 2. Check answer.\n"))
if opt == 1:
c = int(input("Ciphertext in hex: "), 16)
path = toPath(rsa.decrypt(c))
print(final_position(path))
elif opt == 2:
path = input("Solution: ").strip()
if path == solution:
print(flag)
else:
print("Wrong solution.")
exit(0)
else:
print("Invalid option.")
exit(0)
except:
exit(0)
if __name__ == "__main__":
main()
Challenge Overview
This is an RSA decryption oracle challenge, which is nicely packaged to be about finding the solution to a maze (although there isn't really a maze involved :p). On each connection, the server generates a 1024-bit RSA key pair as well as a "solution to the maze". The solution is a 128-bit number, and we are give its ciphertext under the RSA public key. Then, given access to an oracle which tells us the final position of the decrypted path corresponding to a provided ciphertext, we must recover the solution path to get the flag.
toPath and toInt
In this challenge, a "path" is an arbitrary length string consisting only of the characters "LRUD" (left, right, up, down). Conversion between a path and an int is as simple as it gets: to go from an int to a path, we simply look at chunks of two bits (from left to right) and replace them according to the mapping:
00 -> L
01 -> R
10 -> U
11 -> D
For integers with odd bit length, a zero bit is prepended.
As an example, the integer 1337
has binary representation 10100111001
and so the corresponding path is RRLDUR
:
R R L D U R
010100111001
Conversion from a path to an int is simply the inverse of this.
The Decryption Oracle
We can request the oracle as many times as we want. We provide it a ciphertext value and it will decrypt it to obtain . It then converts to a path and then gives us the final (x, y) coordinate one would end up at by following the path. For example, if the ciphertext we provided decrypts to the number 1337
, the oracle would return (2, 0)
.
At first it might seem tricky to extract information out of this, but figuring it out is part of the fun :)
Solution
The solution given here is an approach via LLL. The size of the solution path we are trying to recover is already so small (128 bits compared to the 1024-bit modulus), so we should only need a few more relations on the solution value to recover it.
The solution involves the hidden number problem, so for completeness, we give some background about HNP and EHNP here. Feel free to skip it if you're already familiar.
The Hidden Number Problem
A (simplified) version of the hidden number problem can be stated as follows.
(Hidden number problem). Let be a prime and let be a secret integer. Recover given pairs of integers such that
where the are unknown and satisfy for some .
For appropriate parameters, the HNP can be solved via a reduction to the closest vector problem. Consider the matrix with basis given by
By rewriting the HNP equations as for integers , we see that the linear combination generates the lattice vector . Defining and , we notice that where the length of is bounded above by , whereas the lattice determinant is . Therefore, we can reasonably expect an approximate CVP algorithm to reveal the vector from which we can read off the secret integer by multiplying the last entry by .
The Extended Hidden Number Problem
The extended hidden number problem extends the HNP to the case in which there are multiple chunks of information known about linear relations of the secret integer. Additionally, it simultaneously deals with the case in which multiple chunks of the secret integer are known. It can be stated as follows.
(Extended hidden number problem). Let be a prime and let be a secret integer such that
where the integers and are known, and the unknown integers satisfy for known integers . Suppose we are given equations
for where , and are known integers. The unknown integers are bounded by where the are known. The extended hidden number problem (EHNP) is to find . The EHNP instance is represented by
As with the hidden number problem, we model the situation as a CVP instance. The main idea behind the lattice basis used to solve the EHNP is similar to that of the regular HNP except the EHNP lattice involves factors to deal with the varying sizes of the unknown chunks. For a , we construct the EHNP lattice basis :
with the following definitions:
To understand what vector we should target with CVP, we rewrite the EHNP equations as
for integers . Now, consider the lattice vector generated by the linear combination which contains secret information:
We have
Then, letting
we notice that is close to the lattice vector . Therefore, by solving the CVP instance with as the target vector, we may reveal the lattice vector that encodes the secret chunks in the st to th entries.
Finding the HNP
We can think to use (E)HNP in settings where we have many linear expressions involving a secret value which are bounded by values which are "small" relative to the modulus. Because the homomorphic property of RSA implies malleability, an RSA decryption oracle is an almost perfect situation for this to happen.
We have the path solution ciphertext where is the path solution itself. By querying the oracle with ciphertext values of , we may learn some information about . But what information can we learn exactly?
We remember that our goal is to obtain bounded expressions involving . We're quite close already since the oracle is telling us about properties of . In fact, it turns out that we can learn about the size of using the oracle. The main idea revolves around shifting to the left two bits at a time and observing the new final position.
Let be a random value. We query the oracle with to learn that the final position of the path is . Next, we query the oracle with . This gives us the position of the path . This value is of interest because with high probability, when the two MSBs of are , then the the two LSBs of will also be (because the shifting didn't cause a modulo reduction). Since there is one additional "L" in the path of in this case, in terms of the final position, we see that this will be identifiable when and . We can repeat this process with and so on to get a tighter upper bound on .
With this, all we need to do is find many such that is bounded. We then get the expressions
where . This is precisely an (extended) hidden number problem.
Implementation
from pwn import *
import ast
# https://github.com/josephsurin/lattice-based-cryptanalysis/
from lbc_toolkit import ehnp
directions = "LRUD"
def toPath(x: int):
s = bin(x)[2:]
if len(s) % 2 == 1:
s = "0" + s
path = ""
for i in range(0, len(s), 2):
path += directions[int(s[i:i+2], 2)]
return path
def query(c):
conn.sendlineafter(b'Check answer.\n', b'1')
conn.sendlineafter(b'hex: ', hex(c).encode())
final_pos = ast.literal_eval(conn.recvline().decode().strip())
return final_pos
def blinded_query(r, c):
return query((pow(r, e, n) * c) % n)
def get_flag(sol_int):
path = toPath(sol_int)
conn.sendlineafter(b'Check answer.\n', b'2')
conn.sendlineafter(b'Solution: ', path.encode())
return conn.recvline().decode()
conn = remote('challs.ctf.sekai.team', 3005)
conn.recvline()
n = int(conn.recvline().decode().strip().split('n = ')[1])
e = int(conn.recvline().decode().strip().split('e = ')[1])
c = int(conn.recvline().decode().strip(), 16)
rs_and_Us = []
while len(rs_and_Us) < 30:
r = randint(1, n)
x, y = blinded_query(r, c)
r_ = r
cnt = 0
while True:
r_ = 4*r_
x_, y_ = blinded_query(r_, c)
if x_ != x - 1 or y != y_:
if cnt >= 4:
rs_and_Us.append((r, 1024 - 2*cnt))
print('got!', len(rs_and_Us))
break
x = x_
cnt += 1
xbar = 0
Pi = [0]
Nu = [128]
Alpha = [r for r, _ in rs_and_Us]
ell = len(rs_and_Us)
Rho = [[1]] * ell
Mu = [[U] for _, U in rs_and_Us]
Beta = [0] * ell
sol = ehnp(xbar, n, Pi, Nu, Alpha, Rho, Mu, Beta, delta=1/10^22, verbose=True)
print(sol)
if sol > 2^130:
sol = -sol % n
print(get_flag(sol))
# SEKAI{parity_reveals_everything_:<_8f1261a517796b4d}