# BuckeyeCTF 2021 crypto write-ups
These are write-ups for the crypto challenges I wrote for BuckeyeCTF 2021. Source code and solve scripts can be found here: https://github.com/cscosu/buckeyectf-2021/tree/master/crypto
## Key exchange
**Stats**: 141 solves / 40 points / easy
Let's exchange the flag (securely):
```
nc crypto.chall.pwnoh.io 13374
```
```python
import random
import hashlib
# Mac/Linux: pip3 install pycryptodome
# Windows: py -m pip install pycryptodome
import Crypto.Util.number as cun
from Crypto.Cipher import AES
rand = random.SystemRandom()
FLAG = b"buckeye{???????????????????????????????????????????????????????}"
def diffie_hellman(message: bytes):
p = cun.getPrime(512)
g = 5
print(f"p = {p}")
print(f"g = {g}")
a = rand.randrange(2, p - 1) # private key
A = pow(g, a, p) # public key
# g ^ a === A (mod p)
# It's computationally infeasible for anyone else to derive a from A
print(f"A = {A}")
B = int(input("Give me your public key B: "))
if not (1 < B < p - 1):
print("Suspicious public key")
return
# B ^ a === (g ^ b) ^ a === g ^ (ab) (mod p)
# Nobody can derive this shared secret except us!
shared_secret = pow(B, a, p)
# Use AES, a symmetric cipher, to encrypt the flag using the shared key
key = hashlib.sha1(cun.long_to_bytes(shared_secret)).digest()[:16]
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = cipher.encrypt(message)
print(f"ciphertext = {ciphertext.hex()}")
print("I'm going to send you the flag.")
print("However, I noticed that an FBI agent has been eavesdropping on my messages,")
print("so I'm going to send it to you in a way that ONLY YOU can decrypt the flag.")
print()
diffie_hellman(FLAG)
```
### Solution
This is Diffie-Hellman key exchange: the server plays the role of Alice, and you play the role of Bob.
To calculate the shared key, pick a random $b$ then send $g^b \pmod{p}$. Then the shared key is $A^b \equiv B^a \equiv g^{ab} \pmod{p}$.
```python
import pwn
import Crypto.Util.number as cun
from Crypto.Cipher import AES
import hashlib
import random
if pwn.args.REMOTE:
io = pwn.remote("localhost", 1024)
else:
io = pwn.process("python3 ../deploy/server.py", shell=True)
io.recvuntil("decrypt the flag.\n")
io.recvline()
p = int(io.recvlineS().strip().split(" = ")[-1])
g = int(io.recvlineS().strip().split(" = ")[-1])
A = int(io.recvlineS().strip().split(" = ")[-1])
b = random.randrange(2, p - 1)
B = pow(g, b, p)
io.sendlineafter("Give me your public key B: ", str(B))
ct = bytes.fromhex(io.recvlineS().strip().split(" = ")[-1])
shared_secret = pow(A, b, p)
key = hashlib.sha1(cun.long_to_bytes(shared_secret)).digest()[:16]
cipher = AES.new(key, AES.MODE_ECB)
pt = cipher.decrypt(ct)
print(pt)
```
## Key exchange 2
**Stats**: 34 solves / 90 points / easy
No public key this time!
```
nc crypto.chall.pwnoh.io 13386
```
### Solution
This is the same as the first Key exchange challenge, but no public key $A$ is given. The solution is to perform a small subgroup attack:
- Find any small prime factor $k$ of $p - 1$ (besides 2)
- Set $B \equiv g^{\frac{p - 1}{k}} \pmod{p}$. Then the order of $B$ is at most $k$. That's because $B^k \equiv \left(g^{\frac{p - 1}{k}}\right)^k \equiv g^{p -1} \equiv 1 \pmod{p}$
- The server calculates the shared key as $B^a \pmod{p}$. That means the shared key must be one of $B, B^2, B^3, \ldots, B^k$. Just try all of them:
```python
for i in range(3, 1024):
if (p - 1) % i == 0:
break
else:
io.close()
return False
print(i)
b = (p - 1) // i
B = pow(g, b, p)
```
## Elliptigo
**Stats**: 21 solves / 465 points / medium
I heard everyone uses Curve25519 these days. I'm sure it's super secure, so I'll even let you pick a base point!
```
nc crypto.chall.pwnoh.io 13373
```
```python
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.number import size, long_to_bytes
import os
import hashlib
from collections import namedtuple
FLAG = b"buckeye{???????????????????}"
Point = namedtuple("Point", ("x", "z"))
Curve = namedtuple("Curve", ("a", "b"))
p = 2 ** 255 - 19
C = Curve(486662, 1)
"""
Implements the Montgomery Ladder from https://eprint.iacr.org/2017/212.pdf
"""
def point_add(P: Point, Q: Point, D: Point) -> Point:
"""
Algorithm 1 (xADD)
"""
V0 = (P.x + P.z) % p
V1 = (Q.x - Q.z) % p
V1 = (V1 * V0) % p
V0 = (P.x - P.z) % p
V2 = (Q.x + Q.z) % p
V2 = (V2 * V0) % p
V3 = (V1 + V2) % p
V3 = (V3 * V3) % p
V4 = (V1 - V2) % p
V4 = (V4 * V4) % p
x = (D.z * V3) % p
z = (D.x * V4) % p
return Point(x, z)
def point_double(P: Point) -> Point:
"""
Algorithm 2 (xDBL)
"""
V1 = (P.x + P.z) % p
V1 = (V1 * V1) % p
V2 = (P.x - P.z) % p
V2 = (V2 * V2) % p
x = (V1 * V2) % p
V1 = (V1 - V2) % p
V3 = (((C.a + 2) // 4) * V1) % p
V3 = (V3 + V2) % p
z = (V1 * V3) % p
return Point(x, z)
def scalar_multiplication(P: Point, k: int) -> Point:
"""
Algorithm 4 (LADDER)
"""
if k == 0:
return Point(0, 0)
R0, R1 = P, point_double(P)
for i in range(size(k) - 2, -1, -1):
if k & (1 << i) == 0:
R0, R1 = point_double(R0), point_add(R0, R1, P)
else:
R0, R1 = point_add(R0, R1, P), point_double(R1)
return R0
def normalize(P: Point) -> Point:
if P.z == 0:
return Point(0, 0)
return Point((P.x * pow(P.z, -1, p)) % p, 1)
def legendre_symbol(x: int, p: int) -> int:
return pow(x, (p - 1) // 2, p)
def is_on_curve(x: int) -> bool:
y2 = x ** 3 + C.a * x ** 2 + C.b * x
return legendre_symbol(y2, p) != (-1 % p)
def main():
print("Pick a base point")
x = int(input("x: "))
if size(x) < 245:
print("Too small!")
return
if x >= p:
print("Too big!")
return
if not is_on_curve(x):
print("That x coordinate is not on the curve!")
return
P = Point(x, 1)
a = int.from_bytes(os.urandom(32), "big")
A = scalar_multiplication(P, a)
A = normalize(A)
key = hashlib.sha1(long_to_bytes(A.x)).digest()[:16]
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(FLAG, 16))
print(ciphertext.hex())
if __name__ == "__main__":
main()
```
### Solution
This is the same setup as Key exchange 2, except now the group is Curve25519. Since the order of the group has a small factor of 8, we can do a small subgroup attack again:
```python
# Pick a generator G
G = Point(6, 1)
subgroup_order = (2 ** 252 + 27742317777372353535851937790883648493)
order = 8 * subgroup_order
assert normalize(scalar_multiplication(G, 2)) != Point(0, 0)
assert normalize(scalar_multiplication(G, 4)) != Point(0, 0)
assert normalize(scalar_multiplication(G, 8)) != Point(0, 0)
assert normalize(scalar_multiplication(G, subgroup_order)) != Point(0, 0)
assert normalize(scalar_multiplication(G, order)) == Point(0, 0)
B = normalize(scalar_multiplication(G, order // 8))
assert normalize(scalar_multiplication(B, 8)) == Point(0, 0)
subgroup = [normalize(scalar_multiplication(B, i)) for i in range(1, 8 + 1)]
xs = sorted([B.x for B in subgroup])
shared_secrets = [hashlib.sha1(long_to_bytes(x)).digest()[:16] for x in xs]
io = pwn.process("python3 ../src/chall.py", shell=True)
io.sendlineafter("x: ", str(xs[-1]))
ct = bytes.fromhex(io.recvlineS().strip())
for shared_secret in shared_secrets:
cipher = AES.new(shared_secret, AES.MODE_ECB)
print(cipher.decrypt(ct))
```
## Defective RSA
**Stats**: 33 solves / 441 points / hard
I use whatever exponent I want
```python
from Crypto.Util.number import getPrime, inverse, bytes_to_long
e = 1440
p = getPrime(1024)
q = getPrime(1024)
n = p * q
flag = b"buckeye{???????????????????????????????}"
c = pow(bytes_to_long(flag), e, n)
print(f"e = {e}")
print(f"p = {p}")
print(f"q = {q}")
print(f"c = {c}")
# e = 1440
# p = 108625855303776649594296217762606721187040584561417095690198042179830062402629658962879350820293908057921799564638749647771368411506723288839177992685299661714871016652680397728777113391224594324895682408827010145323030026082761062500181476560183634668138131801648343275565223565977246710777427583719180083291
# q = 124798714298572197477112002336936373035171283115049515725599555617056486296944840825233421484520319540831045007911288562132502591989600480131168074514155585416785836380683166987568696042676261271645077182221098718286132972014887153999243085898461063988679608552066508889401992413931814407841256822078696283307
# c = 4293606144359418817736495518573956045055950439046955515371898146152322502185230451389572608386931924257325505819171116011649046442643872945953560994241654388422410626170474919026755694736722826526735721078136605822710062385234124626978157043892554030381907741335072033672799019807449664770833149118405216955508166023135740085638364296590030244412603570120626455502803633568769117033633691251863952272305904666711949672819104143350385792786745943339525077987002410804383449669449479498326161988207955152893663022347871373738691699497135077946326510254675142300512375907387958624047470418647049735737979399600182827754
```
This is a typical RSA encryption except:
- We're given $p$ and $q$
- $e$ and $\phi\left(n\right)$ are not co-prime, so we don't have a unique decryption.
### Solution
Instead find all x satisfying:
\begin{align}
(m \cdot x)^e &\equiv c \pmod{n} \\
x^e &\equiv 1 \pmod{n}
\end{align}
Actually $x$ is called a [root of unity modulo $n$](https://en.wikipedia.org/wiki/Root_of_unity_modulo_n) which can be computed fairly easily.
Final script:
```python
import Crypto.Util.number as cun
from pprint import pprint
def roots_of_unity(e, phi, n, rounds=500):
# Divide common factors of `phi` and `e` until they're coprime.
phi_coprime = phi
while cun.GCD(phi_coprime, e) != 1:
phi_coprime //= cun.GCD(phi_coprime, e)
# Don't know how many roots of unity there are, so just try and collect a bunch
roots = set(pow(i, phi_coprime, n) for i in range(1, rounds))
assert all(pow(root, e, n) == 1 for root in roots)
return roots, phi_coprime
e = 1440
p = 108625855303776649594296217762606721187040584561417095690198042179830062402629658962879350820293908057921799564638749647771368411506723288839177992685299661714871016652680397728777113391224594324895682408827010145323030026082761062500181476560183634668138131801648343275565223565977246710777427583719180083291
q = 124798714298572197477112002336936373035171283115049515725599555617056486296944840825233421484520319540831045007911288562132502591989600480131168074514155585416785836380683166987568696042676261271645077182221098718286132972014887153999243085898461063988679608552066508889401992413931814407841256822078696283307
c = 4293606144359418817736495518573956045055950439046955515371898146152322502185230451389572608386931924257325505819171116011649046442643872945953560994241654388422410626170474919026755694736722826526735721078136605822710062385234124626978157043892554030381907741335072033672799019807449664770833149118405216955508166023135740085638364296590030244412603570120626455502803633568769117033633691251863952272305904666711949672819104143350385792786745943339525077987002410804383449669449479498326161988207955152893663022347871373738691699497135077946326510254675142300512375907387958624047470418647049735737979399600182827754
n = p * q
# Problem: e and phi are not coprime - d does not exist
phi = (p - 1) * (q - 1)
# Find e'th roots of unity modulo n
roots, phi_coprime = roots_of_unity(e, phi, n)
# Use our `phi_coprime` to get one possible plaintext
d = pow(e, -1, phi_coprime)
m = pow(c, d, n)
assert pow(m, e, n) == c
# Use the roots of unity to get all other possible plaintexts
ms = [(m * root) % n for root in roots]
ms = [cun.long_to_bytes(m) for m in ms]
pprint(ms)
for m in ms:
if m.startswith(b"buckeye"):
print(f"Flag: {m}")
break
else:
print("No flag found :(")
```
If you know a better way to implement `roots_of_unity()`, let me know. I implemented an algorithm from Wikipedia but it doesn't seem very clean.
### Note
This challenge is based on Broken RSA from CryptoHack, but the intended solution is different. Unfortunately, almost nobody did the intended solution and instead did something similar to [Y-CTF's solution](https://github.com/Y-CTF/writeups/blob/main/BuckeyeCTF/crypto/Defective-RSA/defective-RSA.md):
```python
from Crypto.Util.number import long_to_bytes
e = 1440
p = 108625855303776649594296217762606721187040584561417095690198042179830062402629658962879350820293908057921799564638749647771368411506723288839177992685299661714871016652680397728777113391224594324895682408827010145323030026082761062500181476560183634668138131801648343275565223565977246710777427583719180083291
q = 124798714298572197477112002336936373035171283115049515725599555617056486296944840825233421484520319540831045007911288562132502591989600480131168074514155585416785836380683166987568696042676261271645077182221098718286132972014887153999243085898461063988679608552066508889401992413931814407841256822078696283307
c = 4293606144359418817736495518573956045055950439046955515371898146152322502185230451389572608386931924257325505819171116011649046442643872945953560994241654388422410626170474919026755694736722826526735721078136605822710062385234124626978157043892554030381907741335072033672799019807449664770833149118405216955508166023135740085638364296590030244412603570120626455502803633568769117033633691251863952272305904666711949672819104143350385792786745943339525077987002410804383449669449479498326161988207955152893663022347871373738691699497135077946326510254675142300512375907387958624047470418647049735737979399600182827754
rmodp = (c % p).nth_root(e, all=True)
rq = (c % q).nth_root(e)
for rp in rmodp:
r = crt(int(rp), int(rq), p, q)
flag = long_to_bytes(r)
if b"buckeye" in flag:
print(flag.decode())
```
I'm not sure how to prevent these kind of solutions, but I at least should've made the flag much longer (more than 2048 bytes) so that people would actually need to enumerate all possible plaintexts.
## Pseudo
**Stats**: 15 solves / 476 points / hard
Surely the Legendre symbol is a great random number generator, right?
```
nc crypto.chall.pwnoh.io 13375
```
```python
#!/usr/bin/env python3
import random
import os
rand = random.SystemRandom()
FLAG = b"buckeye{?????????????????????}"
def is_prime(n, rounds=32):
return all(pow(rand.randrange(2, n), n - 1, n) == 1 for _ in range(rounds))
class RNG:
def __init__(self, p: int, a: int):
self.p = p
self.a = a
def next_bit(self) -> int:
ans = pow(self.a, (self.p - 1) // 2, self.p)
self.a += 1
return int(ans == 1)
def next_byte(self) -> int:
ans = 0
for i in range(8):
ans |= self.next_bit() << i
return ans
def next_bytes(self, n: int) -> bytes:
return bytes(self.next_byte() for _ in range(n))
def main():
p = int(input("Give me a prime number: "))
if not (256 <= p.bit_length() <= 512):
print("Wrong bit length")
return
if not is_prime(p):
print("Fermat tells me your number isn't prime")
return
a = rand.randrange(2, p)
rng = RNG(p, a)
plaintext = b"Hello " + os.urandom(48).hex().encode()
print("Have some ciphertexts:")
for _ in range(32):
s = rng.next_bytes(len(plaintext))
c = bytes(a ^ b for a, b in zip(s, plaintext))
print(c.hex())
if plaintext == input("Guess the plaintext:\n").encode():
print(f"Congrats! Here's the flag: {FLAG}")
else:
print("That's wrong")
main()
```
In short:
- You input a number
- The server uses the [Fermat primality test](https://en.wikipedia.org/wiki/Fermat_primality_test) to check your number
- It uses the [Legendre PRF](https://legendreprf.org/) to encrypt the flag
- Basically it picks a random number $a$ and generates a stream of pseudo-random bits by calculating the Legendre symbol of $a, a + 1, a + 2, \ldots$
### Solution
When I wrote this problem, my first idea was just to use a Carmichael number in order to pass the Fermat primality test.
```python
def carmichael(start):
start = start if start % 2 == 0 else start - 1
k = start
while True:
p1 = 6 * k + 1
p2 = 12 * k + 1
p3 = 18 * k + 1
if cun.isPrime(p1) and cun.isPrime(p2) and cun.isPrime(p3):
n = p1 * p2 * p3
return n
k += 2
```
This passed the Fermat primality test, but it seemed to produce 50% ones and zeros from the RNG.
I wasn't sure why this was the case, but next I tried [strong pseudoprimes](https://core.ac.uk/download/pdf/81930829.pdf). Specifically, I chose $n = 13618186946913248902029336585225618237728639469119284611739065110030838492720163$ from Lemma 4.2 of the linked paper.
Somehow, this made the RNG produce 97% zero bits in its output, which was more than enough to get the flag. It still isn't clear to me why some Carmichael numbers produce more biased outputs than others, but it seems like an interesting problem.
- Some people like randomdude999, Polymero, and Waffles generated Carmichael numbers similar to the snippet of code aboveābut for some reason their numbers gave them biased RNGs, which got them the flag.
- Shadowwws managed to find a Carmichael number 12222215858006916517610684499755896040093289077713340217662837890484212251483748795117784089 that produced only 1 bits.
## Super VDF
**Stats**: 15 solves / 476 points / medium
You'll need a supercomputer to pass my VDF
```
nc crypto.chall.pwnoh.io 13376
```
```python
from gmpy2 import is_prime, mpz
from random import SystemRandom
rand = SystemRandom()
PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53]
def get_prime(bit_length):
while True:
x = mpz(1)
while x.bit_length() < bit_length:
x *= rand.choice(PRIMES)
if is_prime(x + 1):
return x + 1
def get_correct_answer():
# Implementation redacted
return -1
p = get_prime(1024)
q = get_prime(1024)
n = p * q
print(f"n = {n}")
print("Please calculate (59 ** 59 ** 59 ** 59 ** 1333337) % n")
ans = int(input(">>> "))
if ans == get_correct_answer():
print("WTF do you own a supercomputer? Here's your flag:")
print("buckeye{????????????????????????????????????}")
else:
print("WRONG")
```
### Solution
VDF stands for Verifiable Delay Function: there's an existing VDF where you have to calculate $2^{2^{e}} \pmod{n}$ where $n$ is an RSA modulus, so this is kind of similar.
The solution here is to first factor $n$ with [Pollard's p - 1 algorithm](https://en.wikipedia.org/wiki/Pollard%27s_p_%E2%88%92_1_algorithm). Then you can calculate $\phi \left(n\right), \phi \left(\phi \left(n\right)\right), \phi \left(\phi \left(\phi \left(n\right)\right)\right)$ and so on. This is feasible because each result of $\phi$ is a smooth number.
To solve the challenge, notice that $59^{59^{e}} \equiv 59^{59^{e} \pmod{\phi\left(n \right)}} \pmod{n}$. Do this a few more times to get the answer:
```python
e = 1333337
phi = (p - 1) * (q - 1)
phi1 = euler_phi(phi)
phi2 = euler_phi(phi1)
a = pow(59, e, phi2)
b = pow(59, a, phi1)
c = pow(59, b, phi)
ans = pow(59, c, n)
```
### Note
I also saw some creative solutions from randomdude999 and Yehonatan which did not require factoring $n$.