In this CTF challenge we are given the following server source code:
from fastecdsa.curve import P256
from secrets import randbelow
from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os
def segment(b, n):
l = len(b) // n
return [bytes_to_long(b[i:i+l]) for i in range(0, len(b), l)]
secret = randbelow(2**256)
hidden = randbelow(2**128)
FLAG = os.environ.get("FLAG", "wack{demo_flag}")
class Randomizer:
def __init__(self, seed):
r = randbelow(2**256)
self.Q = P256.G * r
self.P = self.Q * secret
self.state = (self.P * seed).x
def _updatestate(self):
self.state = (self.P * self.state).x
def random(self):
out = (self.Q * self.state).x
self._updatestate()
return out
def get_public_parameters(self):
return self.P, self.Q
def randombytes(self,n):
k = n // 32
r = n % 32
out = b"".join(long_to_bytes(self.random()).rjust(32, b"\x00") for _ in range(k))
if r != 0:
out += long_to_bytes(self.random() % 2**(r*8)).rjust(r, b"\x00")
return out
R = Randomizer(randbelow(2**256))
def get_flag():
c = AES.new(R.randombytes(16), AES.MODE_ECB)
f = c.encrypt(pad(FLAG.encode(), AES.block_size))
return f.hex()
while True:
print("Welcome the agency secure flag server")
print("What do you want to do. (1) Get info, (2) Get random, (3) get flag or (4) get public parameters")
i = input("> ")
match i:
case "1":
a = randbelow(2**256)
b = (hidden << 128) | randbelow(2**128)
c = randbelow(2**256)
d = (pow(b,-1, P256.q)*(a+c*secret) % P256.q)
print(f"Here are some hints\n{a=}\nb=?\n{c=}\n{d=}")
case "2":
print(f"Random value = {R.random()}")
case "3":
print(f"Flag = {get_flag()}")
case "4":
P, Q = R.get_public_parameters()
print(f"P = {P}\nQ = {Q}")
case _:
print("Not a valid option")
The script defines a custom PRNG Randomizer which evolves the state through scalar-multiplication on the P256 elliptic curve. The service presents a menu with different choices, allowing us to get hints about the secret values, call R.random, encrypt the flag, and get the public parameters $P$ and $Q$ where $Q$ is a random point on the curve and $P = secret \cdot Q$.
The flag is encrypted with AES ECB with a 16-byte key generated from Randomizer.randombytes(). To decrypt the flag, we must learn the state of the PRNG.
Menu option 1 Get info returns a set of integers $a,c,d$ where $a$ and $c$ are random 256-bit integers, and:
Here, $s$ is the 256-bit fixed secret, $h$ is the fixed 128-bit hidden integer and $r$ is a random 128-bit value for each query. We can rearrange:
\[\large \begin{align} \nonumber a+c\cdot s &\equiv d\cdot b \mod N\\ \nonumber (a+c \cdot s)d^{-1} &\equiv b \mod N \end{align}\]By subtracting two instances from one-another, we eliminate the fixed unknown $h$ and are left with the small unknown $r_{i} - r_{1}$:
\[\large (a_{i}+c_{i} \cdot s)d_{i}^{-1} - (a_{0}+c_{0} \cdot s)d_{0}^{-1} \equiv r_{i} -r_{0} \mod N\]We can expand the parenthesis $(a+c \cdot s)d^{-1}$ and define two variables, $t$ and $u$:
\[\large \begin{align} \nonumber t_{i} = c_{i}d_{i}^{-1} - c_{0}d_{0}^{-1} \\ \nonumber u_{i} = a_{i}d_{i}^{-1} - a_{0}d_{0}^{-1} \end{align}\]We denote the small random unknown $r_{i}-r_{0}$ as $\beta_{i}$:
\[\large \begin{align} \nonumber t_{i} \cdot s-u_{i} \equiv r_{i}-r_{0} \mod N \\ \nonumber \beta_{i} - t_{i} \cdot s + u_{i} \equiv 0 \mod N \end{align}\]Which is a standard Hidden Number Problem instance with know $t,u$, unknown $s$ and unknown small $\beta$. This instance can be solved using LLL on the standard HNP lattice:
\[\large M = \begin{pmatrix} NI_{n} & 0 & 0 \\ t & B/N & 0 \\ u & 0 & B \end{pmatrix}\]Which has a target vector $v = (\beta_{1},\dots,\beta_{m},s B/N,-B)$, thus recovering the secret $s$.
def HNP(a,t,p,B):
n = len(a)
M = block_matrix([
[identity_matrix(QQ,n)*p, zero_matrix(n,2)],
[matrix(t), matrix([B/p, 0])],
[matrix(a), matrix([0, B])]
])
for row in M.LLL():
if abs(row[-1]) == B:
return row[-2]*p/B % p
The PRNG does:
\[\large \begin{align} \nonumber R_{i} &= (state_{i} \cdot Q).x \\ \nonumber state_{i+1} &= (state_{i} \cdot P).x \\ \nonumber P &= secret \cdot Q \end{align}\]With the secret $s$ and a random value $R_{0}$ from menu option 2, we can lift $R_{1}$ on the curve to recover the point $state_{i} \cdot Q$. Since the next state is computed as $(state_{i} \cdot P).x$, we can substitute for $P$:
\[\large state_{i+1} = (state_{i} \cdot secret \cdot Q).x\]So, by lifting $R_{0}$, we can compute the next state. This state can be used to directly infer the ECB encryption key, if get flag is the next menu option:
Ppub, Qpub = get_PQ(io)
R1 = get_random(io)
state0 = (E.lift_x(R1) * secret).x()
R2 = (Qpub * state0).x()
key = long_to_bytes(int(R2))[-16:]
ct = get_flag(io)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
print(unpad(pt,16).decode())
Which gets us our flag:
wack{N54_4pr0V3d}
from pwn import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
import re
# P-256 params
P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
A = -3
B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
F = GF(P)
E = EllipticCurve(F, [A, B])
def get_info(io):
io.sendlineafter(b"> ", b"1")
io.recvuntil(b"hints\n")
a = int(io.recvline().decode().strip()[2:])
io.recvline() #b?
c = int(io.recvline().decode().strip()[2:])
d = int(io.recvline().decode().strip()[2:])
return a, c, d
def get_random(io):
io.sendlineafter(b"> ", b"2")
return Integer(io.recvline().decode().strip()[15:])
def get_flag(io):
io.sendlineafter(b"> ", b"3")
return bytes.fromhex(io.recvline().decode().strip()[7:])
def get_PQ(io):
io.sendlineafter(b"> ",b"4")
io.recvuntil(b"P = X: ")
Px = Integer(io.recvline().decode().strip())
Py = Integer(io.recvline().decode().strip()[3:])
io.recvuntil(b"Q = X: ")
Qx = Integer(io.recvline().decode().strip())
Qy = Integer(io.recvline().decode().strip()[3:])
return E(Px,Py), E(Qx,Qy)
def HNP(a,t,p,B):
n = len(a)
M = block_matrix([
[identity_matrix(QQ,n)*p, zero_matrix(n,2)],
[matrix(t), matrix([B/p, 0])],
[matrix(a), matrix([0, B])]
])
for row in M.LLL():
if abs(row[-1]) == B:
return row[-2]*p/B % p
io = remote("ctf.wackattack.eu", 8085)
num_samples = 10
samples = [get_info(io) for _ in range(num_samples)]
t_vec, a_vec = [], []
for i in range(1, num_samples):
ai, ci, di = samples[i]
a0, c0, d0 = samples[0]
inv_di = pow(di, -1, N)
inv_d0 = pow(d0, -1, N)
t = (ci * inv_di - c0 * inv_d0) % N
a = (ai * inv_di - a0 * inv_d0) % N
t_vec.append(t)
a_vec.append(a)
B = 2^128
secret = HNP(a_vec, t_vec, N, B)
print(f"Recovered secret: {hex(secret)}")
Ppub, Qpub = get_PQ(io)
R1 = get_random(io)
state0 = (E.lift_x(R1) * secret).x()
R2 = (Qpub * state0).x()
key = long_to_bytes(int(R2))[-16:]
ct = get_flag(io)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
print(unpad(pt,16).decode())
io.close()