# Cyber Jawara International 2024
This is a short writeup for the CTF event [Cyber Jawara International 2024](https://ctftime.org/event/2552/). I participated with team [SNI](https://ctftime.org/team/279998/), where we did a merger with Mager and TCP1P. This collaboration is quite fruitful as we placed first in the event. Despite the event being one day after my graduation and I was still quite busy, I am glad to be able to solve some challenges anyways.
## Misc/Stone Game
The challenge is just a [classic NIM game](https://codeforces.com/blog/entry/66040), which is quite popular in Competitive Programming. The only twist is that the Grundy sum of the piles are `0`, and we go first so if the computer is perfect, we can never win. However, it seems that the challenge allows us to take 0 stones exactly once, and exploiting this, we can easily win. Assuming the bot is perfect, we can just randomly take stones until two piles of size 1 are left, then take 0 stone, and take the remaining stone and win the game.
## Cry/Tempest
This challenge is quite difficult at first. It mostly revolves around the usage of the [ethsnarks](https://github.com/HarryR/ethsnarks/) Python library, where the challenge uses the Eddsa module of this library.
```python=
import time
from random import SystemRandom
from ethsnarks import jubjub, eddsa, field
FLAG = open('flag.txt', 'rb').read()
def serialize(sig: eddsa.Signature):
return (sig.R.compress() + int(sig.s).to_bytes(32, 'big')).hex()
def deserialize(b):
b = bytes.fromhex(b)
R = jubjub.Point.decompress(b[:32])
s = field.FQ(int.from_bytes(b[32:], 'big'))
return R, s
TICKET_PRICE = {
"normal": 10,
"FLAG": 100,
}
TICKET_VALUE = {
"normal": 10,
}
ISSUED_TICKETS = {}
REFUNDED_TICKETS = []
class Tempus:
def __init__(self, username: str):
self.signer = eddsa.MiMCEdDSA()
self.sk, self.pk = self.signer.random_keypair()
self.username = int.from_bytes(username.encode(), 'big')
self.userid = self.signer.hash_public(self.pk, self.username)
self.balance = 10
self.refunded = []
self.issued_tickets = {}
self.rand = SystemRandom()
print(f"Successfully registered with User ID: {self.userid}")
def buy_ticket(self, ticket_type):
price = TICKET_PRICE.get(ticket_type)
if self.balance >= price:
self.balance -= price
new_ticket_id = self.rand.getrandbits(128)
ticket_id = int.to_bytes(new_ticket_id, 16, "big")
signature = self.signer.sign(new_ticket_id, self.sk)
ISSUED_TICKETS[self.signer.hash_public(signature.A, self.username, new_ticket_id)] = ticket_type
signature = serialize(signature.sig)
if ticket_type == "FLAG":
print(FLAG)
else:
print(f"Your ticket: {ticket_id.hex()+signature}")
else:
print("[ERROR] Insufficient balance")
def refund_ticket(self):
try:
ticket = input("Input ticket: ")
ticket_id, signature = bytes.fromhex(ticket[:32]), deserialize(ticket[32:])
if self.signer.verify(self.pk, signature, int.from_bytes(ticket_id, 'big')):
ticket_key = int(input("Input ticket key: "))
if ticket_key in ISSUED_TICKETS and signature not in REFUNDED_TICKETS:
ticket_type = ISSUED_TICKETS.get(ticket_key)
value = TICKET_VALUE.get(ticket_type, 0)
self.balance += value
REFUNDED_TICKETS.append(signature)
print("Ticket succesfully refunded!")
else:
print("[ERROR] Invalid ticket")
else:
print("[ERROR] Invalid ticket")
except Exception:
print("[ERROR] Invalid ticket")
def menu(self):
print("[1] Buy ticket ($10)")
print("[2] Buy FLAG ($100)")
print("[3] Refund ticket")
print("[4] Exit")
print("")
print(f"Your balance: ${self.balance}")
def main():
username = input("Username: ")
ticketer = Tempus(username)
while True:
time.sleep(0.5)
ticketer.menu()
option = input("$> ")
if option == "1":
ticketer.buy_ticket("normal")
elif option == "2":
ticketer.buy_ticket("FLAG")
elif option == "3":
ticketer.refund_ticket()
else:
break
if __name__ == "__main__":
main()
```
There seems to be two main goals of this challenge:
1. Forging either tickets or its signature to refund multiple times.
2. Calculate the `ticket_key` required for actually refunding tickets.
### Forging signatures
After an hour of digging through the library's source code, I found an interesting bug from the verification function from the Eddsa module:
```python=
@classmethod
def verify(cls, A, sig, msg, B=None):
if not isinstance(A, Point):
A = Point(*A)
if not isinstance(sig, Signature):
sig = Signature(*sig)
R, S = sig
B = B or cls.B()
lhs = B * S
M = cls.prehash_message(msg)
rhs = R + (A * cls.hash_public(R, A, M))
return lhs == rhs
```
Notice that the `S` variable from the signature is not validated. This is clearly a vulnerability as `S` should be in the range of `(0, l)` where `l` is the order of the curve. Otherwise, an attacker could forge different `S` values for a known plaintext-signature pair by simply calculating `S'` such that it is equal to `S` modulo `l`.
However, at first glance this bug seems to have been patched in the challenge on the deserialization function:
```python=
def deserialize(b):
b = bytes.fromhex(b)
R = jubjub.Point.decompress(b[:32])
s = field.FQ(int.from_bytes(b[32:], 'big'))
return R, s
```
Here, the `S` variable is placed in a field which automatically does the modulo. Now, notice how the field actually does the modulo:
```python=
# Fq is the base field of Jubjub
SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617
# Fr is the scalar field of Jubjub
FR_ORDER = 21888242871839275222246405745257275088614511777268538073601725287587578984328
```
From the [field source code](https://github.com/HarryR/ethsnarks/blob/master/ethsnarks/field.py), we notice that the `FQ` field actually does the modulo on the `SNARK_SCALAR_FIELD`, whereas the order of the curve is actually the variable `FR_ORDER`. This should be exploitable, but `FR_ORDER` is larger than `SNARK_SCALAR_FIELD` so we still need some optimization. Fortunately, points seem to be in [curve subgroups](https://eips.ethereum.org/EIPS/eip-2494#order) with order `FR_ORDER/8` most of the time, and this value is indeed smaller than `SNARK_SCALAR_FIELD`.
Thus, we can do an exploit by forging new signatures for the same message (voucher) by adding `FR_ORDER/8` to `S` multiple times as long as it is still smaller than `SNARK_SCALAR_FIELD`.
### Calculating Ticket Key
To redeem our forged `(voucher, signature)`, we also need to calculate the proper `ticket_key`.
```python=
ISSUED_TICKETS[self.signer.hash_public(signature.A, self.username, new_ticket_id)] = ticket_type
```
```python=
if ticket_key in ISSUED_TICKETS and signature not in REFUNDED_TICKETS:
ticket_type = ISSUED_TICKETS.get(ticket_key)
value = TICKET_VALUE.get(ticket_type, 0)
self.balance += value
REFUNDED_TICKETS.append(signature)
print("Ticket succesfully refunded!")
else:
print("[ERROR] Invalid ticket")
```
Fortunately, this seems to be easy as firstly, the ticket blacklist is only by the ticket signature (which we can forge), and the actual `ticket_id` and `ticket_key` is not blacklisted.
Digging through the library once more, we see that the hash is done with some `mimc` based algorithm:
```python=
class MiMCEdDSA(_SignatureScheme):
@classmethod
def hash_public(cls, *args, p13n=P13N_EDDSA_VERIFY_RAM):
return mimc_hash(list(as_scalar(*args)), seed=p13n)
```
```python=
def mimc_hash(x, k=0, seed=DEFAULT_SEED, p=SNARK_SCALAR_FIELD, e=DEFAULT_EXPONENT, R=DEFAULT_ROUNDS):
"""
The Miyaguchi–Preneel single-block-length one-way compression
function is an extended variant of Matyas–Meyer–Oseas. It was
independently proposed by Shoji Miyaguchi and Bart Preneel.
H_i = E_{H_{i-1}}(m_i) + {H_{i-1}} + m_i
The previous output is used as the key for
the next iteration.
or..
m_i
|
|----,
| |
v |
H_{i-1}--,-->[E] |
| | |
`-->(+)<--'
|
v
H_i
@param x list of inputs
@param k initial key
"""
for x_i in x:
r = mimc(x_i, k, seed, p, e, R)
k = (k + x_i + r) % p
return k
```
Luckily, it seems that we don't need to know details and just continue the hash from some known state to calculate `ticket_key`. This can be done as the hash is calculated with `hash_public(signature.A, self.username, new_ticket_id)`, whereas we are given `user_id` when connecting to the service which contains the information `hash_public(self.pk, self.username)`. Note that `signature.A` and `self.pk` is the same public key, thus we could just run:
```python
mimc_hash([int.from_bytes(ticket_id, 'big')], k=userid, seed=P13N_EDDSA_VERIFY_RAM)
```
and obtain the same result.
### Solver
```python=
import time
from random import SystemRandom
from pwn import remote, process
from ethsnarks import jubjub, eddsa, field
from ethsnarks.mimc.permutation import mimc_hash
SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617
JUBJUB_E = 21888242871839275222246405745257275088614511777268538073601725287587578984328
JUBJUB_C = 8 # Cofactor
JUBJUB_L = JUBJUB_E//JUBJUB_C
P13N_EDDSA_VERIFY_RAM = 'EdDSA_Verify.RAM'
#r = process(['python3', 'challenge.py'], level='debug')
r = remote('68.183.177.211', 18272, level='debug')
r.sendlineafter(b'Username:', b'azuketto')
r.recvuntil(b'Successfully registered with User ID: ')
userid = int(r.recvline().strip().decode())
def serialize(sig: eddsa.Signature):
return (sig.R.compress() + int(sig.s).to_bytes(32, 'big')).hex()
def deserialize(b):
b = bytes.fromhex(b)
R = jubjub.Point.decompress(b[:32])
s = field.FQ(int.from_bytes(b[32:], 'big'))
return R, s
def buy(choice: int = 1):
r.sendlineafter(b'$> ', str(choice).encode())
if choice == 1:
r.recvuntil(b'Your ticket: ')
return r.recvline().strip().decode()
else: r.interactive()
def redeem(ticket: str, key: str):
r.sendlineafter(b'$> ', b'3')
r.sendlineafter(b':', ticket.encode())
r.sendlineafter(b'key:', key.encode())
ticket = buy()
ticket_id, signature = bytes.fromhex(ticket[:32]), deserialize(ticket[32:])
key = mimc_hash([int.from_bytes(ticket_id, 'big')], k=userid, seed=P13N_EDDSA_VERIFY_RAM)
R, s = signature
for _ in range(15):
sig = (R.compress()+ int(s).to_bytes(32, 'big')).hex()
redeem(ticket_id.hex()+sig , str(key))
if s.n + JUBJUB_L > SNARK_SCALAR_FIELD:
ticket = buy()
ticket_id, signature = bytes.fromhex(ticket[:32]), deserialize(ticket[32:])
key = mimc_hash([int.from_bytes(ticket_id, 'big')], k=userid, seed=P13N_EDDSA_VERIFY_RAM)
R, s = signature
else:
s += JUBJUB_L
buy(2)
```
### Additional Notes
This writeup indeed exposes a 0 day vulnerability of the library. However, after contacting the maintainer of the library, he seems to not have the time to maintain this anymore, and has decided to [deprecate the library](https://github.com/HarryR/ethsnarks/commit/cc5aae9a06267ea9a9afcdb5c48f3f73dfa1467b).
## Cry/Exchange
This is a fairly simple challenge. We are given a near perfect SIDH script, with one tiny flaw:
```python=
import random, os
from sage.all import GF, EllipticCurve
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from secret import k, secret_message
assert k < 2**80
class SIDHKeyExchange:
def __init__(self, ea, eb, Px, Py, Qx, Qy):
self.ea = ea
self.eb = eb
self.p = 2**ea * 3**eb - 1
self.F = GF(self.p**2, modulus=[1, 0, 1], name="i")
self.E0 = EllipticCurve(self.F, [1,0])
self.n = self.p + 1
self.P = self.E0(Px, Py)
self.Q = self.E0(Qx, Qy)
def gen_public_key(self, P, Q, P_other, Q_other, k):
S = P + k * Q
phi = self.E0.isogeny(S, algorithm="factored")
phi_P, phi_Q = phi(P_other), phi(Q_other)
E = phi.codomain()
return E, phi_P, phi_Q
def get_secret(self, E, phi_P, phi_Q, k):
S = phi_P + k * phi_Q
phi = E.isogeny(S, algorithm="factored")
return phi.codomain().j_invariant()
class Participant:
def __init__(self, sidh: SIDHKeyExchange, k=None):
self.sidh = sidh
self.k = k if k else random.randint(0, sidh.n - 1)
self.public_key = None
self.P = None
self.Q = None
self.P_other = None
self.Q_other = None
self.shared_secret = None
def generate_public_key(self):
self.public_key = self.sidh.gen_public_key(self.P, self.Q, self.P_other, self.Q_other, self.k)
def compute_shared_secret(self, other_public_key):
E, phi_P, phi_Q = other_public_key
self.shared_secret = self.sidh.get_secret(E, phi_P, phi_Q, self.k)
def encrypt_message(self, message):
key = SHA256.new(data=str(self.shared_secret).encode()).digest()
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
ct = cipher.encrypt(pad(message, 16))
return iv.hex(), ct.hex()
class Server(Participant):
def __init__(self, sidh: SIDHKeyExchange, k=None):
super().__init__(sidh, k)
self.P = (sidh.n // 2**sidh.ea) * sidh.P
self.Q = (sidh.n // 2**sidh.ea) * sidh.Q
self.P_other = (sidh.n // 3**sidh.eb) * sidh.P
self.Q_other = (sidh.n // 3**sidh.eb) * sidh.Q
# params
ea, eb = 216, 137
Px, Py = "19629002260899253283957946747466897522475660429050952494632348990720012643804186952501717429174343892395039800185878841093698753933*i + 21000588886550169078507395472843540543638380042132530088238158553508923414317518502680121549885061095065934470295106985227640283273", "13560249428793295825208984866590149450862696800181907880173464654955696194787095135464283707858591438577968786063706580965498135934*i + 21227525746542853125364742330159420698250203945016980815891234503309307307571119288025866772923240030378597416196914652205098993851"
Qx, Qy = "20653307324266777301745266224745462280019066788351952077152874786877832545975431386178311539432979313717724415603792104905464166613*i + 23543412524221765729252528306934564386341553623491347201198996167100895162007001691940922919130176638905146054229833255452149631903", "20303099683704256985792197307242241000101523982229217515269602102967877919445169730337445309155396940855065338113695974349986136276*i + 18114250244485018537853099412437907124848306019791525046747068726333108423955919092536186121751860512435214970727124588973589483061"
sidh = SIDHKeyExchange(ea, eb, Px, Py, Qx, Qy)
server = Server(sidh, k)
server.generate_public_key()
E, phi_P, phi_Q = server.public_key
print("Server public key")
print("E")
print(f"Ea4: {E.a4()}")
print(f"Ea6: {E.a6()}")
print("phi_P")
print(f"x: {phi_P[0]}")
print(f"y: {phi_P[1]}")
print("phi_Q")
print(f"x: {phi_Q[0]}")
print(f"y: {phi_Q[1]}")
print()
print("Input your public key")
print("Input elliptic curve")
Ea4 = input("Ea4: ")
Ea6 = input("Ea6: ")
E = EllipticCurve(sidh.F, [Ea4, Ea6])
print("Input phi_P")
x = input("x: ")
y = input("y: ")
phi_P = E(x, y)
print("Input phi_Q")
x = input("x: ")
y = input("y: ")
phi_Q = E(x, y)
other_public_key = (E, phi_P, phi_Q)
server.compute_shared_secret(other_public_key)
iv, ct = server.encrypt_message(secret_message)
print("Encrypted message")
print(f"iv: {iv}")
print(f"ct: {ct}")
```
The flaw is located around the initializations of the elliptic curves. Basically, sagemath does not immediately know the order of the curve, and needs to compute it from scratch. This is not necessary as we know that the order of the curve is `(p+1)**2`. Thus, we can just do a patch by adding lines such as `E.set_order(sidh.n ** 2)`, and just run the SIDH protocol.
I will not explain how the protocol works in this writeup as other sources will do a better job, e.g. [Wikipedia](https://en.wikipedia.org/wiki/Supersingular_isogeny_key_exchange)
### Solver
```python=
import random, os
from pwn import process, remote
from sage.all import GF, EllipticCurve
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from secret import k, secret_message
k = 23
#r = process(['sage', '--python', 'server.py'], level='debug')
r = remote('152.42.183.87', 20003, level='debug')
class SIDHKeyExchange:
def __init__(self, ea, eb, Px, Py, Qx, Qy):
self.ea = ea
self.eb = eb
self.p = 2**ea * 3**eb - 1
self.F = GF(self.p**2, modulus=[1, 0, 1], name="i")
self.E0 = EllipticCurve(self.F, [1,0])
self.n = self.p + 1
self.P = self.E0(Px, Py)
self.Q = self.E0(Qx, Qy)
def gen_public_key(self, P, Q, P_other, Q_other, k):
S = P + k * Q
phi = self.E0.isogeny(S, algorithm="factored")
phi_P, phi_Q = phi(P_other), phi(Q_other)
E = phi.codomain()
return E, phi_P, phi_Q
def get_secret(self, E, phi_P, phi_Q, k):
S = phi_P + k * phi_Q
phi = E.isogeny(S, algorithm="factored")
return phi.codomain().j_invariant()
class Participant:
def __init__(self, sidh: SIDHKeyExchange, k=None):
self.sidh = sidh
self.k = k if k else random.randint(0, sidh.n - 1)
self.public_key = None
self.P = None
self.Q = None
self.P_other = None
self.Q_other = None
self.shared_secret = None
def generate_public_key(self):
self.public_key = self.sidh.gen_public_key(self.P, self.Q, self.P_other, self.Q_other, self.k)
def compute_shared_secret(self, other_public_key):
E, phi_P, phi_Q = other_public_key
self.shared_secret = self.sidh.get_secret(E, phi_P, phi_Q, self.k)
def encrypt_message(self, message):
key = SHA256.new(data=str(self.shared_secret).encode()).digest()
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
ct = cipher.encrypt(pad(message, 16))
return iv.hex(), ct.hex()
def decrypt_message(self, ct, iv):
key = SHA256.new(data=str(self.shared_secret).encode()).digest()
cipher = AES.new(key, AES.MODE_CBC, iv)
pt = cipher.decrypt(ct)
return pt
class Server(Participant):
def __init__(self, sidh: SIDHKeyExchange, k=None):
super().__init__(sidh, k)
self.P_other = (sidh.n // 2**sidh.ea) * sidh.P
self.Q_other = (sidh.n // 2**sidh.ea) * sidh.Q
self.P = (sidh.n // 3**sidh.eb) * sidh.P
self.Q = (sidh.n // 3**sidh.eb) * sidh.Q
# params
ea, eb = 216, 137
Px, Py = "19629002260899253283957946747466897522475660429050952494632348990720012643804186952501717429174343892395039800185878841093698753933*i + 21000588886550169078507395472843540543638380042132530088238158553508923414317518502680121549885061095065934470295106985227640283273", "13560249428793295825208984866590149450862696800181907880173464654955696194787095135464283707858591438577968786063706580965498135934*i + 21227525746542853125364742330159420698250203945016980815891234503309307307571119288025866772923240030378597416196914652205098993851"
Qx, Qy = "20653307324266777301745266224745462280019066788351952077152874786877832545975431386178311539432979313717724415603792104905464166613*i + 23543412524221765729252528306934564386341553623491347201198996167100895162007001691940922919130176638905146054229833255452149631903", "20303099683704256985792197307242241000101523982229217515269602102967877919445169730337445309155396940855065338113695974349986136276*i + 18114250244485018537853099412437907124848306019791525046747068726333108423955919092536186121751860512435214970727124588973589483061"
sidh = SIDHKeyExchange(ea, eb, Px, Py, Qx, Qy)
server = Server(sidh, k)
server.generate_public_key()
r.recvuntil("Ea4: ")
Ea4 = r.recvline().strip().decode()
r.recvuntil(b'Ea6: ')
Ea6 = r.recvline().strip().decode()
E = EllipticCurve(sidh.F, [Ea4, Ea6])
print("Input phi_P")
r.recvuntil(b'x: ')
x = r.recvline().strip().decode()
r.recvuntil(b'y:')
y = r.recvline().strip().decode()
phi_P = E(x, y)
print("Input phi_Q")
r.recvuntil(b'x: ')
x = r.recvline().strip().decode()
r.recvuntil(b'y:')
y = r.recvline().strip().decode()
phi_Q = E(x, y)
E2, phi_P2, phi_Q2 = server.public_key
print(f"E: {E2.a4()}")
r.sendlineafter(b'Ea4:', str(E2.a4()).encode())
r.sendlineafter(b'Ea6:', str(E2.a6()).encode())
r.sendlineafter(b'x:', str(phi_P2[0]).encode())
r.sendlineafter(b'y:', str(phi_P2[1]).encode())
r.sendlineafter(b'x:', str(phi_Q2[0]).encode())
r.sendlineafter(b'y:', str(phi_Q2[1]).encode())
E.set_order(sidh.n ** 2)
other_public_key = (E, phi_P, phi_Q)
server.compute_shared_secret(other_public_key)
r.recvuntil(b'iv: ')
iv = r.recvline().strip().decode()
iv = bytes.fromhex(iv)
r.recvuntil(b'ct: ')
ct = r.recvline().strip().decode()
ct = bytes.fromhex(ct)
print(server.decrypt_message(ct, iv))
```
### Additional notes
The solver only solves part 1 of the challenge, which obtains the following message:
```python
b"here is the first flag CJ{i50g3ny_1z_r3a11y_c00l}, and the second flag is long_to_bytes(k) wrap with '{}'\x07\x07\x07\x07\x07\x07\x07"
```
The second part can be easily solved by running the [Castryck-Decru attack](https://eprint.iacr.org/2022/975), but I didn't have time for this as the script can take quite a while for the parameter size provided in the challenge.