6th February 2024 / Document No. D24.102.32
Prepared By: aris
Challenge Author(s): aris
Difficulty: Hard
Classification: Official
- In this challenge the player is called to recover the IV from a TEA-CBC encryption oracle. Having IV, they must find four distinct keys such that they all encrypt to the same ciphertext. The goal is to implement the "TEA equivalent keys attack" which reduces the efficient keyspace of TEA from 128 bits to 126 bits.
- You find yourself in the middle of a deadly ancient maze. The maze sprawls before you, its secrets veiled in shadows, its gates locked tight against intruders. With thousands of keys shimmering under the harsh light, you steel yourself for the daunting challenge ahead. Each chamber of the maze presents a new puzzle to unravel, each gate a barrier to overcome. Armed with determination and resolve, you set forth into the labyrinth's depths, knowing that your survival hinges on unlocking the path forward by finding the proper key. With each new chamber you enter, you are greeted with a cup of tea—a brief respite from the perilous journey that lies ahead. But the tea is not the only gift bestowed upon you in these chambers. With each cup, you receive a hint that will guide you on how to move on.
- Familiar with Python source code auditing.
- Familiar with finding the correct keywords to research for a problem online.
- Good knowledge of how the TEA cipher works.
- Familiar with bitwise operations.
- Learn about the equivalent keys attack in TEA cipher.
- Become more familiar with translating a problem into keywords to search online.
- Low-level understanding of how addition and the bitwise XOR operation work.
In this challenge, we are provided with two files:
server.py
: This is the main script that runs when we connect to the challenge instance.tea.py
: This is the same file that was also provided in the challengeIced Tea
. It is an implementation of the TEA cipher in ECB and CBC mode.
Let us analyze the main flow of the server script.
from tea import Cipher as TEA
from secret import IV, FLAG
import os
server_message = os.urandom(20)
print(f'Here is my special message: {server_message.hex()}')
used_keys = []
ciphertexts = []
for i in range(ROUNDS):
print(f'Round {i+1}/10')
try:
ct = bytes.fromhex(input('Enter your target ciphertext (in hex) : '))
assert ct not in ciphertexts
for j in range(4):
key = bytes.fromhex(input(f'[{i+1}/{j+1}] Enter your encryption key (in hex) : '))
assert len(key) == 16 and key not in used_keys
used_keys.append(key)
cipher = TEA(key, IV)
enc = cipher.encrypt(my_message)
if enc != ct:
print(f'Hmm ... close enough, but {enc.hex()} does not look like {ct.hex()} at all! Bye...')
exit()
except:
print('Nope.')
exit()
ciphertexts.append(ct)
print(f'Wait, really? {FLAG}')
Let us denote the encryption key as
-
$M$ , which is the message to be encrypted. This parameter is required in both ECB and CBC modes. -
$IV$ , which is the IV that will be used by the cipher. This parameter is used only in CBC mode.
To get the flag, we must successfully complete a specific task 10 times in a row. The task is explained below:
- The server generates a random global message
$M$ that will be reused for each round. - First we are asked to submit our target ciphertext
$ct$ in hexadecimal format. - Then, we have to provide four distinct 16-byte encryption keys
$k_0, k_1, k_2, k_3$ . - For each
$k_i$ , the server computes$C_i = E_{k_i}(M, IV)$ . If any$C_i$ is not equal to$ct$ , the script is terminated.
Last but not least, it should be noted that in case
Steps 2-4 are repeated 10 times and if we pass all the tests, we get the flag.
At this point, it is safe to assume that the task is to find four distinct keys
-
We do not know the IV used by the server to encrypt
$M$ . -
We are not permitted to submit the same ciphertext more than once, due to the assertion check:
assert ct not in ciphertexts
To be able to produce the same ciphertext as the server, we need to know the IV so that should be our first step before figuring out how to send
Since we know both the message to encrypt, the corresponding ciphertext and we get to choose the symmetric key, recovering the IV of the cipher is an easy task to do. Let us recall how the CBC encryption works. The ciphertext is computed as follows:
$$
C_i = \left{
\begin{array}{ll}
E_K(M_i \oplus IV) & , & i = 0\
E_K(M_i \oplus C_{i-1}) & , & i>0
\end{array}
\right.
$$
Similarly, decryption is algebraically defined as:
$$
M_i = \left{
\begin{array}{ll}
D_K(C_i) \oplus IV & , & i = 0\
D_K(C_i) \oplus C_{i-1}) & , & i>0
\end{array}
\right.
$$
The IV is included only in the first block of the entire message. Looking at the decryption routine, we can solve for the
cipher = TEA(key, IV)
enc = cipher.encrypt(my_message)
if enc != ct:
print(f'Hmm ... close enough, but {enc.hex()} does not look like {ct.hex()} at all! Bye...')
exit()
The server encrypts its target message with CBC mode and this print message reveals the corresponding ciphertext. Therefore we obtain the encrypted message and use the first block as
The idea is:
- Pick a 16-byte encryption key full of null bytes.
- Take the server's message and encrypt it using this key. Since we do not have the IV, we will encrypt it using ECB mode.
- Send the ciphertext to the server.
- The server will check whether our ciphertext matches its own. However, it will encrypt its message using the secret IV in CBC mode. As we do not have access to the IV, the verification check is guaranteed to fail but due to the printed message, we obtain the CBC ciphertext.
- We can use the first block of the CBC ciphertext as
$C_0$ to compute the$IV$ .
Note that this method works because the
Let us write a function that implements these steps and recovers the server IV.
def recover_iv():
io = remote(HOST, PORT)
io.recvuntil(b'message: ')
server_message = bytes.fromhex(io.recvline().decode())
key = b'\x00'*16
ct = TEA(key).encrypt(server_message) # encrypt with ECB
io.sendlineafter(b'(in hex) : ', ct.hex().encode())
io.sendlineafter(b'(in hex) : ', key.hex().encode())
io.recvuntil(b'but ')
enc_server_msg = bytes.fromhex(io.recv(48).decode()) # get CBC ciphertext
dec_msg = decrypt_block(key, enc_server_msg[:8]) # compute D_K(C_0)
iv = xor(server_message[:8], dec_msg[:8]) # iv = M_0 XOR D_K(C_0)
return iv
The function decrypt_block
is exactly the same as in the Iced Tea
challenge:
from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
def decrypt_block(key, ct):
m0 = b2l(ct[:4])
m1 = b2l(ct[4:])
msk = (1 << 32) - 1
DELTA = 0x9e3779b9
s = 0xc6ef3720
for i in range(32):
m1 -= ((m0 << 4) + key[2]) ^ (m0 + s) ^ ((m0 >> 5) + key[3])
m1 &= msk
m0 -= ((m1 << 4) + key[0]) ^ (m1 + s) ^ ((m1 >> 5) + key[1])
m0 &= msk
s -= DELTA
m = ((m0 << 32) + m1) & ((1 << 64) - 1)
return l2b(m)
Having recovered the IV successfully, we move on to the next stage of the challenge. We have to submit four distinct keys
def encrypt_block(self, msg):
m0 = b2l(msg[:4])
m1 = b2l(msg[4:])
K = self.KEY
msk = (1 << (self.BLOCK_SIZE//2)) - 1
s = 0
for i in range(32):
s += self.DELTA
m0 += ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
m0 &= msk
m1 += ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
m1 &= msk
m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1
return l2b(m)
Recall that 1
to 0
. This is the normal behaviour of the XOR operation.
Let
While this helps, getting back to our challenge, we see that there is addition as well. That is:
((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
It turns out that the MSB flipping propagates through both addition and XOR. Note that we keep only the last 32 bits of the following additions.
$$
(K_0 + m) \pmod {2^{32}} = 01011110101011011011111011101111\
(K_1 + m) \pmod {2^{32}} = 01001010111111101011101010111110
$$
Therefore, we can get the same result for the two distinct pairs
TEA has a few weaknesses. Most notably, it suffers from equivalent keys—each key is equivalent to three others, which means that the effective key size is only 126 bits.
The article has a reference to this paper which explains this attack in Chapter 2.5. Officially, this attack is known as the TEA equivalent keys attack. Alternatively, one could find this Master thesis that also describes this attack in Chapter 3.5.
Let us write a function that takes a single key as an argument and produces four equivalent keys that do not affect the TEA encryption process.
from pwn import xor
def get_equivalent_keys(key):
m = l2b(1 << 31)
k0, k1, k2, k3 = [key[i:i+4] for i in range(0, len(key), 4)]
key0 = k0 + k1 + k2 + k3
key1 = k0 + k1 + xor(k2, m) + xor(k3, m)
key2 = xor(k0, m) + xor(k1, m) + k2 + k3
key3 = xor(k0, m) + xor(k1, m) + xor(k2, m) + xor(k3, m)
return [key0, key1, key2, key3]
All that is left to do is connect to the server and solve the task 10 times in a row to get the flag. Let us write a function that solves a single task first.
import os
from tea import Cipher as TEA
def solve_task(server_message, iv):
key = os.urandom(16)
keys = get_equivalent_keys(key)
ct = TEA(key, iv).encrypt(server_message)
assert all([ct == TEA(k, iv).encrypt(server_message) for k in keys]), 'Something went wrong'
io.sendlineafter(b'(in hex) : ', ct.hex().encode())
for j in range(4):
io.sendlineafter(b'(in hex) : ', keys[j].hex().encode())
return True
Now let us write a function that receives the server message and solves the task 10 times.
def get_flag(iv):
io = remote(HOST, PORT)
io.recvuntil(b'message: ')
server_msg = bytes.fromhex(io.recvline().decode())
for i in range(10):
assert solve_task(server_msg, iv)
flag = io.recvline().decode()
return flag
A final summary of all that was said above:
- Notice that to get the flag, we have to solve a task 10 times in a row.
- The task is to submit four distinct keys
$k_0, k_1, k_2, k_3$ and a ciphertext$ct$ such that$ct = E_{k_0}(M) = E_{k_1}(M) = E_{k_2}(M) = E_{k_3}(M)$ . - However the IV is unknown to us so we probably have to recover it.
- Recover the IV by sending a ciphertext that is guaranteed to fail and extract the server's CBC ciphertext from the outputted error message. Then simply solve for the IV using XOR.
- Find out about the equivalent keys attack on the TEA cipher, either manually or by researching and apply it to solve the task 10 times.
This recap can be represented by code with the pwn()
function:
HOST = 'localhost'
PORT = 1337
def pwn():
iv = recover_iv()
flag = get_flag(iv)
print(flag)
if __name__ == '__main__':
pwn()