forbytten blogs

Iced TEA Writeup - Cyber Apocalypse 2024

Last update:

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.

Iced TEA challenge description

2 Key Techniques

The key techniques employed in this writeup are:

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:

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):
    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)

In this decrypt function:

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:

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.

blocks = [msg[i:i+self.BLOCK_SIZE//8] for i in range(0, len(msg), self.BLOCK_SIZE//8)]

The decomposition of a message into blocks can be visualized in an IPython session:

  1. Define the block size:

    In [1]: BLOCK_SIZE = 64
  2. 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
  3. 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:

self.KEY = [b2l(key[i:i+self.BLOCK_SIZE//16]) for i in range(0, len(key), self.BLOCK_SIZE//16)]

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.

self.DELTA = 0x9e3779b9

6.2 Understanding the encrypt_block function

Lines 42-43 of the encrypt_block function contain the following:

m0 = b2l(msg[:4])
m1 = b2l(msg[4:])

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:

  1. Create an 8 byte long test message, where the high order 4 bytes all contain 0xFF and the low order 4 bytes all contain 0xEE:

    In [19]: msg = b'\xFF'*4 + b'\xEE'*4
    
    In [20]: msg
    Out[20]: b'\xff\xff\xff\xff\xee\xee\xee\xee'
  2. 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'
  3. 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:

K = 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:

msk = (1 << (self.BLOCK_SIZE//2)) - 1

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 55, as indicated by the comment in the original source code, concatenates m0 and m1.

m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1

In detail:

Line 57 converts the resulting long back to bytes:

return l2b(m)

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:

m = b2l(msg)

Line 55 requires extracting the 4 high order bytes into m0 and the 4 low order bytes into m1:

  1. m0 can be created by shifting half the block size of bits off to the right, discarding the 4 low order bytes

    m0 = m >> (self.BLOCK_SIZE // 2)
  2. m1 can be created by using the same mask used on line 45:

    msk = (1 << (self.BLOCK_SIZE // 2)) - 1
    m1 = m & msk

Lines 47-53 is a bit trickier to reverse due to the loop but it can be analyzed as follows:

Lines 44-45 are just declarations and can be left untouched and placed anywhere in the function prior to their first use:

K = self.KEY
msk = (1 << (self.BLOCK_SIZE//2)) - 1

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:

m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1
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

Submission of the flag marked the challenge as pwned