UofTCTF 2024
Export Grade Cipher [crypto 10 solve]
chall
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import ast
import threading
from exportcipher import *
try:
from flag import FLAG
except:
FLAG = "test{FLAG}"
MAX_COUNT = 100
TIMEOUT = 120 # seconds
def input_bytes(display_msg):
m = input(display_msg)
try:
m = ast.literal_eval(m)
except:
# might not be valid str or bytes literal but could still be valid input, so just encode it
pass
if isinstance(m, str):
m = m.encode()
assert isinstance(m, bytes)
return m
def timeout_handler():
print("Time is up, you can throw out your work as the key changed.")
exit()
if __name__ == "__main__":
print("Initializing Export Grade Cipher...")
key = int.from_bytes(os.urandom(5),"little")
cipher = ExportGradeCipher(key)
print("You may choose up to {} plaintext messages to encrypt.".format(MAX_COUNT))
print("Recover the 40-bit key to get the flag.")
print("You have {} seconds.".format(TIMEOUT))
# enough time to crack a 40 bit key with the compute resources of a government
threading.Timer(TIMEOUT, timeout_handler).start()
i = 0
while i < MAX_COUNT:
pt = input_bytes("[MSG {}] plaintext: ".format(i))
if not pt:
break
if len(pt) > 512:
# don't allow excessively long messages
print("Message Too Long!")
continue
nonce = os.urandom(256)
cipher.init_with_nonce(nonce)
ct = cipher.encrypt(pt)
print("[MSG {}] nonce: {}".format(i, nonce))
print("[MSG {}] ciphertext: {}".format(i, ct))
# sanity check decryption
cipher.init_with_nonce(nonce)
assert pt == cipher.decrypt(ct)
i += 1
recovered_key = ast.literal_eval(input("Recovered Key: "))
assert isinstance(recovered_key, int)
if recovered_key == key:
print("That is the key! Here is the flag: {}".format(FLAG))
else:
print("Wrong!")
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import os
class LFSR:
def __init__(self, seed, taps, size):
assert seed != 0
assert (seed >> size) == 0
assert len(taps) > 0 and (size - 1) in taps
self.state = seed
self.taps = taps
self.mask = (1 << size) - 1
def _shift(self):
feedback = 0
for tap in self.taps:
feedback ^= (self.state >> tap) & 1
self.state = ((self.state << 1) | feedback) & self.mask
def next_byte(self):
val = self.state & 0xFF
for _ in range(8):
self._shift()
return val
class ExportGradeCipher:
def __init__(self, key):
# 40 bit key
assert (key >> 40) == 0
self.key = key
self.initialized = False
def init_with_nonce(self, nonce):
# 256 byte nonce, nonce size isnt export controlled so hopefully this will compensate for the short key size
assert len(nonce) == 256
self.lfsr17 = LFSR((self.key & 0xFFFF) | (1 << 16), [2, 9, 10, 11, 14, 16], 17)
self.lfsr32 = LFSR(((self.key >> 16) | 0xAB << 24) & 0xFFFFFFFF, [1, 6, 16, 21, 23, 24, 25, 26, 30, 31], 32)
self.S = [i for i in range(256)]
# Fisher-Yates shuffle S-table
for i in range(255, 0, -1):
# generate j s.t. 0 <= j <= i, has modulo bias but good luck exploiting that
j = (self.lfsr17.next_byte() ^ self.lfsr32.next_byte()) % (i + 1)
self.S[i], self.S[j] = self.S[j], self.S[i]
j = 0
# use nonce to scramble S-table some more
for i in range(256):
j = (j + self.lfsr17.next_byte() ^ self.lfsr32.next_byte() + self.S[i] + nonce[i]) % 256
self.S[i], self.S[j] = self.S[j], self.S[i]
self.S_inv = [0 for _ in range(256)]
for i in range(256):
self.S_inv[self.S[i]] = i
self.initialized = True
def _update(self, v):
i = self.lfsr17.next_byte() ^ self.lfsr32.next_byte()
self.S[v], self.S[i] = self.S[i], self.S[v]
self.S_inv[self.S[v]] = v
self.S_inv[self.S[i]] = i
def encrypt(self, msg):
assert self.initialized
ct = bytes()
for v in msg:
ct += self.S[v].to_bytes()
self._update(v)
return ct
def decrypt(self, ct):
assert self.initialized
msg = bytes()
for v in ct:
vo = self.S_inv[v]
msg += vo.to_bytes()
self._update(vo)
return msg
if __name__ == "__main__":
cipher = ExportGradeCipher(int.from_bytes(os.urandom(5)))
nonce = os.urandom(256)
print("="*50)
print("Cipher Key: {}".format(cipher.key))
print("Nonce: {}".format(nonce))
msg = "ChatGPT: The Kerckhoffs' Principle, formulated by Auguste Kerckhoffs in the 19th century, is a fundamental concept in cryptography that states that the security of a cryptographic system should not rely on the secrecy of the algorithm, but rather on the secrecy of the key. In other words, a cryptosystem should remain secure even if all the details of the encryption algorithm, except for the key, are publicly known. This principle emphasizes the importance of key management in ensuring the confidentiality and integrity of encrypted data and promotes the development of encryption algorithms that can be openly analyzed and tested by the cryptographic community, making them more robust and trustworthy."
print("="*50)
print("Plaintext: {}".format(msg))
cipher.init_with_nonce(nonce)
ct = cipher.encrypt(msg.encode())
print("="*50)
print("Ciphertext: {}".format(ct))
cipher.init_with_nonce(nonce)
dec = cipher.decrypt(ct)
print("="*50)
try:
print("Decrypted: {}".format(dec))
assert msg.encode() == dec
except:
print("Decryption failed")
solve
sumaary
The class ExportGradeCipher is implemented in ExportGradeCipher.py.
-
In this class, the state is initialized by the function
init_with_nonce
using key and nonce. -
After that, the function
_update
constructs a nfsr(non linear shift register) oflfsr17
andlfsr32
, and appears to perform encryption using them. -
In
encrypt
, after outputting the location of one character of msg in the S array as ct, the output of 256 bytes of nfsr and the location of one character of msg are swapped.1 2 3 4 5 6 7 8 9 10 11
def _update(self, v): i = self.lfsr17.next_byte() ^ self.lfsr32.next_byte() self.S[v], self.S[i] = self.S[i], self.S[v] def encrypt(self, msg): assert self.initialized ct = bytes() for v in msg: ct += self.S[v].to_bytes(1,"little") self._update(v) return ct
cryptanisis
Suppose we have an S array of length 4 and msg is b”\x00\x01\x02\x03”
i | 0 | 1 | 2 | 3 |
---|---|---|---|---|
S[i] | 2 | 4 | 6 | 8 |
-
If you follow the processing of the
encrypt
function in order, the first character is whenv=0
and thect
at that time isct[0]=2
. -
The
self._update(v)
causes a swapself.S[v], self.S[i] = self.S[i], self.S[v]
. We know thatv=0
here, so let’s assumei=1
. Then the table transitions as follows.
i | 0 | 1 | 2 | 3 |
---|---|---|---|---|
S[i] | 4 | 2 | 6 | 8 |
- We see that v in the second week is
v=1
and that ct at that time isct[1]=2
. I want you to wait a moment. This time, the array S is all set to different values, so the same valuect[0]=ct[1]=2
could be used for something.S[v], self.S[i] = self.S[i], self.S[v]
means thatv
can set itself and swap its value with the i-th value, and if the same value appears here, we know that it was swapped with thei
-th value in the process at this time. So, at this point, we know the outputi
of the nfsr when the same value appears for the first time.
- The rest is a repeat of this.
By the time you reach the end, you have obtained a certain amount of S array and nfsr output i
. And considering that the nonce changes only S array in this setup, repeating the above process up to the upper limit will give us almost perfect nfsr output.
solver
Therefore, now that we have obtained the output of nfsr, we can also reverse the initial state of lfsr32 by considering that lfsr17 is within the brute force range. At this point, checking whether the upper 2bytes are such that they are 0xAB
will reduce the number of candidate solutions.
1
self.lfsr32 = LFSR(((self.key >> 16) | 0xAB << 24) & 0xFFFFFFFF, [1, 6, 16, 21, 23, 24, 25, 26, 30, 31], 32)
Nevertheless, since about 30 candidates for the key remain, and considering that this cipher can only affect nonce and key, the key can be checked if the cipher is the same for that plaintext by performing the cipher again.
Thus, the following is my implementation. Note that I have used a matrix to speed up the lfsr.
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
from pwn import *
import ast
import random
from tqdm import tqdm
from exportcipher import *
class LFSR:
def __init__(self, seed, taps, size):
assert seed != 0
assert (seed >> size) == 0
assert len(taps) > 0 and (size - 1) in taps
self.state = seed
self.size = size
self.taps = taps
self.mask = (1 << size) - 1
self.mat = matrix(GF(2),size,size)
for i in range(size-1):
self.mat[i,i+1] = 1
for t in self.taps:
self.mat[-1,-t-1] = 1
self.state_vec = vector(GF(2),[int(i) for i in bin(seed)[2:].zfill(size)])
def _shift(self):
feedback = 0
for tap in self.taps:
feedback ^^= (self.state >> tap) & 1
self.state = ((self.state << 1) | feedback) & self.mask
def next_byte(self):
val = self.state & 0xFF
for _ in range(8):
self._shift()
return val
def _shift_mat(self, k):
state_vec = self.mat^(8*k)*self.state_vec
return int("".join([str(i) for i in list(state_vec)[-8:]]),2)
def after_255_256_next_byte(self):
val1 = self._shift_mat(255+256)
val2 = self._shift_mat(256+256)
val3 = self._shift_mat(257+256)
val4 = self._shift_mat(258+256)
return bytes([val1,val2,val3,val4])
def check(self, vec):
vec = vector(GF(2),[int(i) for i in bin(vec[0])[2:].zfill(8)+bin(vec[1])[2:].zfill(8)+bin(vec[2])[2:].zfill(8)+bin(vec[3])[2:].zfill(8)])
self.inv_mat = self.mat^(-1)
if list(self.inv_mat^(8*514)*vec)[:8] == [int(i)for i in bin(0xab)[2:].zfill(8)]:
return int("".join([str(i) for i in list(self.inv_mat^(8*514)*vec)[8:]]),2)
return False
def bxor(a,b): return bytes([_a^^_b for _a,_b in zip(a,b)])
lfsr17s = []
lfsr32s = []
for key in tqdm(range(256**2)):
lfsr17 = LFSR((key & 0xFFFF) | (1 << 16), [2, 9, 10, 11, 14, 16], 17)
lfsr17s.append(lfsr17.after_255_256_next_byte())
io = remote("0.cloud.chals.io","23753")
# io = process(["python3","chal.py"])
CNT = 256
lfsr = [-1 for i in range(CNT)]
pt = [(i)%256 for i in range(CNT)]
for __ in tqdm(range(100)):
random.shuffle(pt)
io.sendlineafter(b": ",str(bytes(pt)).encode())
nonce = ast.literal_eval(io.recvline().decode().split("nonce: ")[1])
ct = ast.literal_eval(io.recvline().decode().split("ciphertext: ")[1])
S = [-1 for i in range(256)]
print(lfsr.count(-1))
for i in range(CNT):
if not ct[i] in S:
S[pt[i]] = ct[i]
else:
if lfsr[ct[:i].index(ct[i])] != -1:
continue
lfsr[ct[:i].index(ct[i])] = pt[i]
S[pt[i]] = ct[i]
S[S.index(ct[i])] = -1
lfsr32 = LFSR(((0x123456 >> 16) | 0xAB << 24) & 0xFFFFFFFF, [1, 6, 16, 21, 23, 24, 25, 26, 30, 31], 32)
for i in tqdm(range(256**2)):
a = lfsr32.check(bxor(bytes(lfsr[:4]),lfsr17s[i]))
if a != False:
key1 = i
key2 = a
key = key1 + (key2 << 16)
cipher = ExportGradeCipher(key)
cipher.init_with_nonce(nonce)
if ct == cipher.encrypt(pt):
io.sendlineafter(b": ", str(key))
io.interactive()
exit()
# uoftctf{wH0_w0u1D_h4ve_7houGHt_l0ng_nONceS_CAnt_S4ve_w3ak_KeYS}