Iced TEA Writeup - Cyber Apocalypse 2024
→ 1 Introduction
This writeup covers the Iced TEA Crypto challenge from the Hack The Box Cyber Apocalypse 2024 CTF, which was rated as having an ‘easy’ difficulty. The challenge involved implementing the decryption function corresponding to an encryption function that was provided in Python source code.
The description of the challenge is shown below.
→ 2 Key Techniques
The key techniques employed in this writeup are:
- Manual Python source code review
-
Solving the challenge in two different ways
- Using Perplexity AI to easily and quickly generate part of the decryption code
- Manually creating the decryption code
→ 3 Artifacts Summary
The downloaded artifact had the following hash:
$ shasum -a256 crypto_iced_tea.zip
b4d6d9e625bf61a0d9f9a09ea31432a1416bda6ecae22887322ec2dc1df45017 crypto_iced_tea.zip
The zip file contained a single Python source code file,
source.py
, and output.txt
:
$ unzip crypto_iced_tea.zip
Archive: crypto_iced_tea.zip
creating: crypto_iced_tea/
inflating: crypto_iced_tea/source.py
inflating: crypto_iced_tea/output.txt
$ shasum -a256 crypto_iced_tea/*
b521e676eb94750b78e4b502885a8ca46962f2d66249c81c69f8fbe34559fe90 crypto_iced_tea/output.txt
1ee61336bb323eb8c033a3be96f4130ba2dcdd831a00fecec1fba0febae310d9 crypto_iced_tea/source.py
→ 4 Static Analysis
→ 4.1 output.txt
output.txt
contains hex encoded ciphertext and a 16 byte
long, hex encoded encryption key:
Key : 850c1413787c389e0b34437a6828a1b2
Ciphertext : b36c62d96d9daaa90634242e1e6c76556d020de35f7a3b248ed71351cc3f3da97d4d8fd0ebc5c06a655eb57f2b250dcb2b39c8b2000297f635ce4a44110ec66596c50624d6ab582b2fd92228a21ad9eece4729e589aba644393f57736a0b870308ff00d778214f238056b8cf5721a843
→ 4.2 source.py
source.py
contains Python code that encrypts the flag.
Notable features:
-
In the ‘main’ block:
- Line 62: securely generates a 16 byte long key.
-
Line 63: creates a new instance of
Cipher
with the key but no iv. -
Line 64: uses the
Cipher
instance to encrypt the flag.
-
In the
Cipher
class:- Line 14: internally, the key is decomposed into a list of 4 byte blocks, with each block converted to a long.
- Line 16-20: uses an encryption mode of ECB (Electronic Code Book) since the constructor was not passed an iv.
-
Line 26: pads the
msg
to ensure the length of the message is a multiple of the block size. - Line 27: breaks the message up into 8 byte blocks.
- Line 32: encrypts each block in the message independently, as expected from ECB mode.
-
Line 41: the
encrypt_block
function is responsible for encrypting each block of 8 bytes. Further analysis of the function will be performed later in the writeup.
import os
from secret import FLAG
from Crypto.Util.Padding import pad
from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
from enum import Enum
class Mode(Enum):
ECB = 0x01
CBC = 0x02
class Cipher:
def __init__(self, key, iv=None):
self.BLOCK_SIZE = 64
self.KEY = [b2l(key[i:i+self.BLOCK_SIZE//16]) for i in range(0, len(key), self.BLOCK_SIZE//16)]
self.DELTA = 0x9e3779b9
self.IV = iv
if self.IV:
self.mode = Mode.CBC
else:
self.mode = Mode.ECB
def _xor(self, a, b):
return b''.join(bytes([_a ^ _b]) for _a, _b in zip(a, b))
def encrypt(self, msg):
msg = pad(msg, self.BLOCK_SIZE//8)
blocks = [msg[i:i+self.BLOCK_SIZE//8] for i in range(0, len(msg), self.BLOCK_SIZE//8)]
ct = b''
if self.mode == Mode.ECB:
for pt in blocks:
ct += self.encrypt_block(pt)
elif self.mode == Mode.CBC:
X = self.IV
for pt in blocks:
enc_block = self.encrypt_block(self._xor(X, pt))
ct += enc_block
X = enc_block
return ct
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)
if __name__ == '__main__':
KEY = os.urandom(16)
cipher = Cipher(KEY)
ct = cipher.encrypt(FLAG)
with open('output.txt', 'w') as f:
f.write(f'Key : {KEY.hex()}\nCiphertext : {ct.hex()}')
→ 5 Implementing the decryptor the fast/easy/cheat way
→ 5.1 Reversing the encrypt_block algorithm
Implementing the decryptor hinges on reversing the algorithm
implemented by the encrypt_block
function. This is a little
bit time consuming to do manually. I have to admit to
cheating
making full use of the resources available to any
competitor and asking Perplexity
AI to reverse the algorithm for me:
how do I write a decrypt function corresponding to the following encrypt?
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)
To write a decrypt function corresponding to the provided encrypt function, you need to reverse the operations performed during encryption. Here is an example of a decrypt function that corresponds to the given encrypt function:
def decrypt_block(self, msg):
= b2l(msg)
m = (1 << (self.BLOCK_SIZE // 2)) - 1
msk = self.KEY
K
= m >> (self.BLOCK_SIZE // 2)
m0 = m & msk
m1
= self.DELTA * 32
s for i in range(32):
-= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
m1 &= msk
m1 -= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
m0 &= msk
m0 -= self.DELTA
s
return l2b((m0 << 32) + m1)
In this decrypt function:
-
The input
msg
is converted back to two partsm0
andm1
. - The operations performed during encryption are reversed to obtain the original message.
-
The final message is reconstructed by combining
m0
andm1
. - The decrypted message is returned after converting it back to bytes format.
This decrypt function should correspond to the provided encrypt function and reverse the encryption process to retrieve the original message.
→ 5.2 Adapting source.py to decrypt the ciphertext
source.py
was modified to incorporate the Perplexity AI generated code and
decrypt the ciphertext as follows:
- Line 2: the FLAG import is commented out.
-
Line 3:
unpad
is imported. -
Line 59: a
decrypt
function is added. It is similar to theencrypt
function except that it:- Only incorporates the ECB mode code.
-
Swaps the usage of the
pt
(plaintext) andct
(ciphertext) variables. -
Calls
decrypt_block
instead ofencrypt_block
. -
Instead of padding the input message, calls
unpad
to remove padding from the decrypted text.
-
Line 68: the
decrypt_block
function is the Perplexity AI generated code. - Line 89-92: modified to decrypt using the key and ciphertext from output.txt.
import os
#from secret import FLAG
from Crypto.Util.Padding import pad, unpad
from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
from enum import Enum
class Mode(Enum):
ECB = 0x01
CBC = 0x02
class Cipher:
def __init__(self, key, iv=None):
self.BLOCK_SIZE = 64
self.KEY = [b2l(key[i:i+self.BLOCK_SIZE//16]) for i in range(0, len(key), self.BLOCK_SIZE//16)]
self.DELTA = 0x9e3779b9
self.IV = iv
if self.IV:
self.mode = Mode.CBC
else:
self.mode = Mode.ECB
def _xor(self, a, b):
return b''.join(bytes([_a ^ _b]) for _a, _b in zip(a, b))
def encrypt(self, msg):
msg = pad(msg, self.BLOCK_SIZE//8)
blocks = [msg[i:i+self.BLOCK_SIZE//8] for i in range(0, len(msg), self.BLOCK_SIZE//8)]
ct = b''
if self.mode == Mode.ECB:
for pt in blocks:
ct += self.encrypt_block(pt)
elif self.mode == Mode.CBC:
X = self.IV
for pt in blocks:
enc_block = self.encrypt_block(self._xor(X, pt))
ct += enc_block
X = enc_block
return ct
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)
def decrypt(self, msg):
blocks = [msg[i:i+self.BLOCK_SIZE//8] for i in range(0, len(msg), self.BLOCK_SIZE//8)]
pt = b''
for ct in blocks:
pt += self.decrypt_block(ct)
return unpad(pt, self.BLOCK_SIZE//8)
# Perplexity generated
def decrypt_block(self, msg):
m = b2l(msg)
msk = (1 << (self.BLOCK_SIZE // 2)) - 1
K = self.KEY
m0 = m >> (self.BLOCK_SIZE // 2)
m1 = m & msk
s = self.DELTA * 32
for i in range(32):
m1 -= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
m1 &= msk
m0 -= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
m0 &= msk
s -= self.DELTA
return l2b((m0 << 32) + m1)
if __name__ == '__main__':
KEY = bytes(bytearray.fromhex('850c1413787c389e0b34437a6828a1b2'))
cipher = Cipher(KEY)
pt = cipher.decrypt(bytes(bytearray.fromhex('b36c62d96d9daaa90634242e1e6c76556d020de35f7a3b248ed71351cc3f3da97d4d8fd0ebc5c06a655eb57f2b250dcb2b39c8b2000297f635ce4a44110ec66596c50624d6ab582b2fd92228a21ad9eece4729e589aba644393f57736a0b870308ff00d778214f238056b8cf5721a843')))
print(f'pt: {pt}')
→ 6 Implementing the decrypt_block function the knowledgeable way
The biggest benefit from any CTF is the learning experience so this
section describes in detail how to understand the
encrypt_block
function and, thus, how to manually derive
the decrypt_block
function. Please note that this section
only delves into understanding the mechanics of the code - it does not
cover any underlying encryption algorithm theory.
→ 6.1 Understanding the Cipher state variables
→ 6.1.1 self.BLOCK_SIZE
During encryption, the block size is used on line 27 to divide the
message into blocks of bytes. Given the block size is divided by 8, the
number of bits in a byte, this means the initial assignment of
self.BLOCK_SIZE = 64
equals a block size of 64 bits.
The decomposition of a message into blocks can be visualized in an IPython session:
-
Define the block size:
In [1]: BLOCK_SIZE = 64
-
Create a 16 byte long test message, where the high order 8 bytes all contain 0xFF and the low order 8 bytes all contain 0xEE:
In [2]: msg = b'\xFF'*8 + b'\xEE'*8
-
Visualize the block decomposition:
In [3]: blocks = [msg[i:i+BLOCK_SIZE//8] for i in range(0, len(msg), BLOCK_SIZE//8)] In [4]: blocks Out[4]: [b'\xff\xff\xff\xff\xff\xff\xff\xff', b'\xee\xee\xee\xee\xee\xee\xee\xee']
→ 6.1.2 The self.KEY variable
The self.KEY
variable is produced by dividing the key
into 4 byte blocks and converting each block from bytes to a long:
Using the hex key 850c1413787c389e0b34437a6828a1b2
from
the challenge, this can be visualized in an IPython session:
$ python3 -mvenv venv
$ . ./venv/bin/activate
$ pip install pycryptodome
$ ipython
<snip/>
In [1]: from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
In [2]: BLOCK_SIZE = 64
In [4]: key = bytes(bytearray.fromhex('850c1413787c389e0b34437a6828a1b2'))
In [5]: KEY = [b2l(key[i:i+BLOCK_SIZE//16]) for i in range(0, len(key), BLOCK_SIZE//16)]
In [6]: KEY
Out[6]: [2232161299, 2021406878, 187974522, 1747493298]
→ 6.1.3 DELTA
self.DELTA
is just a constant number, 4 bytes long.
→ 6.2 Understanding the encrypt_block function
Lines 42-43 of the encrypt_block
function contain the
following:
The msg
argument is known to be 8 bytes long, the length
of a block. Line 42 takes the 4 high order bytes and converts them to a
long, whereas Line 43 does the same for the 4 low order bytes.
Visualizing this in IPython:
-
Create an 8 byte long test message, where the high order 4 bytes all contain
0xFF
and the low order 4 bytes all contain0xEE
:In [19]: msg = b'\xFF'*4 + b'\xEE'*4 In [20]: msg Out[20]: b'\xff\xff\xff\xff\xee\xee\xee\xee'
-
Compute
m0
as both a long and bytes:In [21]: m0 = b2l(msg[:4]) ...: In [22]: m0 Out[22]: 4294967295 In [23]: l2b(m0) Out[23]: b'\xff\xff\xff\xff'
-
Similarly, compute
m1
:In [26]: m1 = b2l(msg[4:]) In [27]: m1 Out[27]: 4008636142 In [28]: l2b(m1) Out[28]: b'\xee\xee\xee\xee'
Line 44 is just self.KEY
:
As seen earlier, for the hex key
850c1413787c389e0b34437a6828a1b2
, self.KEY
is:
[2232161299, 2021406878, 187974522, 1747493298]
Line 45 is a 4 byte mask of all 1s:
Visualizing this in IPython:
In [1]: hex((1 << (64//2)) - 1)
Out[1]: '0xffffffff'
Lines 47-53 is where the encryption actually occurs, mixing bits of the message with itself and the key:
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
In detail:
-
Line 47:
s
starts at 0. - Line 48: a loop of 32 iterations is made.
-
Line 49:
s
is incremented byself.DELTA
in each iteration. -
Line 50:
m0
is incremented by a number each iteration. The amount to increment is calculated fromm1
,s
and numbers fromK
. -
Line 51: only the lower 4 bytes of
m0
are kept. -
Lines 52-53: very similar to the previous two lines but for
m1
this time and the calculation uses them0
that was just modified in the current iteration.
Line 55, as indicated by the comment in the original source code,
concatenates m0
and m1
.
In detail:
-
((m0 << (self.BLOCK_SIZE//2)) + m1)
shiftsm0
left by 32 bits (4 bytes) so thatm0
becomes the high order 4 bytes of the result, then addsm1
so thatm1
becomes the low order 4 bytes of the result. -
& ((1 << self.BLOCK_SIZE) - 1)
masks the result with 8 bytes of 1s, ensuring the result is only 8 bytes wide.
Line 57 converts the resulting long back to bytes:
→ 6.3 Manually creating the decrypt_block function
Manually creating the decrypt_block
function amounts to
stepping backwards through the encrypt_block
and reversing
each step:
Line 57 requires converting bytes back to a long:
= b2l(msg) m
Line 55 requires extracting the 4 high order bytes into
m0
and the 4 low order bytes into m1
:
-
m0
can be created by shifting half the block size of bits off to the right, discarding the 4 low order bytes= m >> (self.BLOCK_SIZE // 2) m0
-
m1
can be created by using the same mask used on line 45:= (1 << (self.BLOCK_SIZE // 2)) - 1 msk = m & msk m1
Lines 47-53 is a bit trickier to reverse due to the loop but it can be analyzed as follows:
-
After the final iteration:
-
The value of
m0
used to calculatem1
is placed into the 4 high order bytes ofm
on line 55. Thus,m0
at the start of the reversed iteration is known. -
s
is also known, being32 * self.DELTA
after the final iteration. -
K
is known because it is from the key.
-
The value of
-
Thus, the
m1
calculation can be reversed by subtracting the known right hand side:-= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3]) m1
-
The low order bits of arithmetic are not affected by what happens to the high order bits, hence the masking is symmetrical and can be applied after the subtraction1:
&= msk m1
-
After
m1
is calculated, them1
that was used to calculatem0
is now known and hencem0
can be calculated in a similar fashion:-= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1]) m0 &= msk m0
-
s
then needs to be decremented prior to the next iteration:-= self.DELTA s
-
Putting it all together results in the following loop:
= 32 * self.DELTA s for i in range(32): -= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3]) m1 &= msk m1 -= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1]) m0 &= msk m0 -= self.DELTA s
Lines 44-45 are just declarations and can be left untouched and placed anywhere in the function prior to their first use:
= self.KEY
K = (1 << (self.BLOCK_SIZE//2)) - 1 msk
Lines 42-43 simply require assembling the two halves of
m0
and m1
back into a full block. This step
can reuse the exact same code from lines 55-57 in
encrypt_block
:
= ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1
m return l2b(m)
Putting it all together results in a function that is very similar to the function generated by Perplexity AI:
def decrypt_block(self, msg):
m = b2l(msg)
m0 = m >> (self.BLOCK_SIZE // 2)
msk = (1 << (self.BLOCK_SIZE // 2)) - 1
m1 = m & msk
K = self.KEY
s = 32 * self.DELTA
for i in range(32):
m1 -= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
m1 &= msk
m0 -= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
m0 &= msk
s -= self.DELTA
m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1
return l2b(m)
→ 7 Obtaining the flag
The script was run and the flag was obtained, where the fag indicates
the encryption algorithm used is the Tiny
Encryption Algorithm. Both the Perplexity AI generated and
manually created decrypt_block
functions produce the same
result.
$ python3 -mvenv venv
$ . ./venv/bin/activate
$ pip install pycryptodome
$ python3 source.py
pt: b'HTB{th1s_1s_th3_t1ny_3ncryp710n_4lg0r1thm_____y0u_m1ght_h4v3_4lr34dy_s7umbl3d_up0n_1t_1f_y0u_d0_r3v3rs1ng}'
→ 8 Conclusion
The flag was submitted and the challenge was marked as pwned