Write-up for crypto/The Door to the Xord
This is a write-up for an easy-medium crypto challenge written by CodeTiger for LITCTF 2023 called The Door to the Xord.
(It could be because I’ve seen a similar challenge before, but I pretty much knew what to do from the get-go.)
Brief intro about myself
I am Neobeo, and I play as a crypto main for Social Engineering Experts. We weren’t planning to tryhard this competition, and in fact finished nowhere near the top at 52nd.
For the most part, I was only really interested at the unblooded crypto, so that was all I looked at. And that was only about 18 hours after the competition started.
There were three unblooded crypto at that time, and by some miracle I managed to blood all of them. They were:
Official challenge statement
crypto/The Door to the Xord by w0152
You must solve this puzzle to meet the Xord, the Xor Lord. > Wrap your flag with LITCTF{}. Connect with
nc litctf.org 31771
Attachment: yourdidit.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python3
import random
from Crypto.Util.number import long_to_bytes as ltb, bytes_to_long as btl
with open("flag.txt", "rb") as f:
x = f.read().strip()
assert len(x) == 32
def xor(x, y):
# the better way to xor strings
# (just in case they have different length, one will be treated as if it was rjusted with \0s)
return ltb(btl(x)^btl(y))
while True:
input("Press enter to get some gibberish: ")
blen = len(x)*8
val = random.getrandbits(blen)
print(xor(x, ltb(val)).hex())
High-level challenge description
The setup is extremely simple and minimalistic2. You have a 32-byte flag which can be XORed with random.getrandbits(256)
as many times as you want (by pressing enter). And that’s it.
The exploit itself
One-time pads are typically information-theoretically secure, as long as the pad itself is random (in a cryptographic sense). However, we are using python’s random
module, which uses the Mersenne Twister. This is a PRNG that is surprisingly easy to break!
There are various websites that detail how MT19937 is implemented in python, such as here and here, so I won’t go into much detail here.
But the main key points we require for this exploit are:
- MT19937 is entirely linear (so you can compose with XOR and it remains linear)
- It has 199683 bits of state, which is usually represented as 624 32-bit words ($624 \times 32 = 19968$)
The usual way we do MT exploits is to know 624 consecutive 32-bit outputs, and then use these to predict other values going forwards or backwards. However, in this case we don’t actually directly know any of the PRNG outputs, just the output XORed with the flag. This is what makes the challenge different to other typical MT challenges.
But once you understand that it’s linear, none of the other detail matters, and we can just implement MT19937 in Z3.
So how many outputs do we need? Equivalently, how many unknown bits are there in the state as a whole? Well, the PRNG has 19968 bits of state (actually 19937, but let’s err on the side of caution). And then the flag has another 256 bits (again, if we know it’s ASCII then the most significant bits are unset which makes it $32 \times 7 = 224$). So we need $\frac{19968+256}{256}=79$ outputs.
Solve script
So basically, the main thing is to implement the MT19937 PRNG in Z3 rather than direct python (namely a twist and a temper). That accounts for the 19968 bits. And then another 32 bits for the flag which we will represent as a BitVec
.
The only other thing to take care is which way to concatenate the 32-bit outputs into the 256-bit value. Let’s quickly test how this works.
1
2
3
4
5
import random
random.seed(0)
print([hex(random.getrandbits(32)) for _ in range(8)])
random.seed(0)
print(hex(random.getrandbits(256)))
This prints the following:
1
2
['0xd82c07cd', '0x629f6fbe', '0xc2094cac', '0xe3e70682', '0x6baa9455', '0xa5d2f34', '0x42485e3a', '0xf728b4fa']
0xf728b4fa42485e3a0a5d2f346baa9455e3e70682c2094cac629f6fbed82c07cd
which indicates that it concatenates “backwards”, so to speak.
So a single output (from pressing enter) is equivalent to getting eight consecutive 32-bit words from the PRNG, concatenating them in reverse, then XORing it with the flag. Simple!
The solve script is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pwn import *
from z3 import *
with remote('litctf.org', 31771) as sh:
sh.sendlines([b'']*79)
arr = [int(sh.readline().decode().split()[-1], 16) for _ in range(79)]
MT = [BitVec(f'm{i}', 32) for i in range(624)]
s = Solver()
def cache(x):
tmp = Const(f'c{len(s.assertions())}', x.sort())
s.add(tmp == x)
return tmp
def temper(y):
y ^= LShR(y, 11)
y = cache(y ^ (y << 7) & 0x9D2C5680)
y ^= cache((y << 15) & 0xEFC60000)
return y ^ LShR(y, 18)
def getnext():
x = Concat(Extract(31, 31, MT[0]), Extract(30, 0, MT[1]))
y = If(x & 1 == 0, BitVecVal(0, 32), 0x9908B0DF)
MT.append(cache(MT[397] ^ LShR(x, 1) ^ y))
return temper(MT.pop(0))
flag = BitVec('flag', 256)
def getmsg():
return Concat([getnext() for _ in range(8)][::-1]) ^ flag
s.add([getmsg() == z for z in arr])
assert s.check() == sat
print(s.model()[flag].as_long().to_bytes(32,'big'))
The cache
does nothing from a purely logical/correctness point of view, but it helps to speed up the solver by “saving” intermediate values.
Anyway, the script runs in about 15 seconds, and prints out:
1
b'you_have_opened_xa_xoor_382d102f'
The flag is LITCTF{you_have_opened_xa_xoor_382d102f}
.
-
Ok, I lied. Here’s my write-up. ↩
-
I get to say this because my challenges also tend to have very few lines. Do check out my one-liner crypto challenge! ↩
-
Technically only 19937 bits are needed (thus the name), and you can fully determine the other 31 bits from the 19937. ↩