LINECTF 2021 / Published

babycrypto2

Flipping bits in the IV that the server prepends to AES-CBC ciphertext, so the first block decrypts to show instead of test.

crypto

4 min read

Challenge brief

nc 35.200.39.68 16002

babycrypto2.py

Inspecting the command service

In this challenge we are provided with the ip and port of a network service and a python script which seemingly contains the code of the network service. Lets have a look at the code shall we?

Python
flag = open("flag", "rb").read().strip()

AES_KEY = get_random_bytes(AES.block_size)
TOKEN = b64encode(get_random_bytes(AES.block_size*10-1))
COMMAND = [b'test',b'show']
PREFIX = b'Command: '

The code reads in the flag from a file, defines 2 commands: test and show, generates a token, generates a key, and defines a prefix.

Python
class AESCipher:
    def __init__(self, key):
        self.key = key

    def encrypt(self, data):
        iv = get_random_bytes(AES.block_size)
        self.cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return b64encode(iv + self.cipher.encrypt(pad(data, AES.block_size)))

    def encrypt_iv(self, data, iv):
        self.cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return b64encode(iv + self.cipher.encrypt(pad(data, AES.block_size)))

    def decrypt(self, data):
        raw = b64decode(data)
        self.cipher = AES.new(self.key, AES.MODE_CBC, raw[:AES.block_size])
        return unpad(self.cipher.decrypt(raw[AES.block_size:]), AES.block_size)


def run_server(client):
    client.send(b'test Command: ' + AESCipher(AES_KEY).encrypt(PREFIX+COMMAND[0]+TOKEN) + b'\n')
    while(True):
        client.send(b'Enter your command: ')
        tt = client.recv(1024).strip()
        tt2 = AESCipher(AES_KEY).decrypt(tt)
        client.send(tt2 + b'\n')
        if tt2 == PREFIX+COMMAND[1]+TOKEN:
            client.send(b'The flag is: ' + flag)
            client.close()
            break

In summary, the logic of run_server() is as follows:

  1. Print an encrypted test command AESCipher(aes_key).encrypt(prefix + b'test' + token)
  2. Continuously accepts (encrypted) commands from us, decrypts it, sends us the decrypted command, and sends us the flag if the decrypted command is prefix + b'show' + token

Additionally we note that more specifically (with reference to the AESCipher class) the commands are being encrypted with AES-128-CBC. We know that the block size is 16 bytes (128 bits) because AES.block_size yields 16.

Controlling the first block through the IV

Python
def encrypt(self, data):
    iv = get_random_bytes(AES.block_size)
    self.cipher = AES.new(self.key, AES.MODE_CBC, iv)
    return b64encode(iv + self.cipher.encrypt(pad(data, AES.block_size)))

def decrypt(self, data):
    raw = b64decode(data)
    self.cipher = AES.new(self.key, AES.MODE_CBC, raw[:AES.block_size])
    return unpad(self.cipher.decrypt(raw[AES.block_size:]), AES.block_size)

The crux of this challenge lies in the encrypt() and decrypt() methods. Instead of just giving us the encrypted data, encrypt() also prepends the iv. Additionally, decrypt() makes use of the prepended iv to do the decryption. This is a fatal flaw in the implementation because we now have control of what the first block decrypts to.

Diagram of AES decryption in CBC mode

With reference to the first block in the diagram above, notice how the IV directly influences (is XOR-ed with) the the output of D(C1, K)D(C_1,~K) to produce plaintext P1P_1. More formally,

D(C1, K) ⊕ IV = P1D(C_1,~K)~\oplus~\text{IV}~=~P_1

Deriving the IV bit flip

Now lets consider what the first block of plaintext for the test command AESCipher(aes_key).encrypt(prefix + b'test' + token) (sent to us) would look like.

Text
First Block Visualized
======================
____________________________________________________________________________________________________
|                      PREFIX                         |        Command        |        Token       |
____________________________________________________________________________________________________
| 'C' | 'o' | 'm' | 'm' | 'a' | 'n' | 'd' | ':' | ' ' | 't' | 'e' | 's' | 't' | t[0] | t[1] | t[2] |
----------------------------------------------------------------------------------------------------
   1     2     3     4     5     6     7     8     9     10    11    12    13    14     15     16

Considering that:

  1. The original IV (which we shall define as IVoriginal\text{IV}_\text{original} ) is sent to us.
  2. We can modify the IV before we send it in to be decrypted

We can easily change ‘test’ to ‘show’ by sending in a “poisoned” IV. How does this work?

Suppose,

D(C, K) ⊕ IVoriginal = test D(C,~K)~\oplus~\text{IV}_\text{original}~=~\text{test}

Then we XOR both sides by (test ⊕ show)(\text{test}~\oplus~\text{show}),

D(C, K) ⊕ IVoriginal ⊕ (test ⊕ show) = test ⊕ (test ⊕ show)D(C, K) ⊕ IVoriginal ⊕ (test ⊕ show) = show\begin{gathered} D(C,~K)~\oplus~\text{IV}_\text{original}~\oplus~(\text{test}~\oplus~\text{show})~=~\text{test}~\oplus~(\text{test}~\oplus~\text{show}) \\ D(C,~K)~\oplus~\text{IV}_\text{original}~\oplus~(\text{test}~\oplus~\text{show})~=~\text{show} \end{gathered}

Voilà! The ‘test’ command is now ‘show’. So now let’s suppose,

IVpoison = IVoriginal ⊕ test ⊕ show\text{IV}_{\text{poison}}~=~\text{IV}_{\text{original}}~\oplus~\text{test}~\oplus~\text{show}

We can now do,

D(C, K) ⊕ IVpoison = showD(C,~K)~\oplus~\text{IV}_\text{poison}~=~\text{show}

Aligning the command bytes

Awesome, looks like what we need! But in reality, not quite. In practice, we cannot just XOR ‘test’ and ‘show’ with the original IV directly due to alignment.

Looking back at the first block, notice how the command we are trying to mutate is at zero-based offsets 9 to 12 (positions 10 to 13 in the table above). Therefore, we need to pad it to the correct position. How do we achieve this without corrupting PREFIX and TOKEN? We can use the identity element for XOR (0 i.e. Null Byte) which gives us,

A ⊕ 0 = AA~\oplus~0~=~A

Therefore we can construct IVpoison\text{IV}_{\text{poison}} as follows:

Python
iv_poison = xor(iv_original, b'\x00' * 9 + xor(b'test', b'show') + b'\x00' * 3)
The 16-byte IV mask that turns test into show in the first blockThe first plaintext block holds Command: and a space at offsets 0 to 8, test at 9 to 12 and three unknown token bytes at 13 to 15. The mask is nine zero bytes, 07 0d 1c 03 (test xor show), then three zero bytes, so XORing it into the IV changes only offsets 9 to 12. The block decrypts to Command: show with the same token bytes, and C1 to C15 are sent unchanged.offset (0-based)plaintextxor maskresultprefixcommandtoken0123456789101112131415Command:SPtestT0T1T2000000000000000000070d1c03000000test xor showCommand:SPshowT0T1T2IV′ = IV xor mask. C1 … C15 are sent unchanged, so later blocks decrypt as before.
Figure 1. Only offsets 9 to 12 of the mask are nonzero, so XORing it into the IV turns test into show and leaves the prefix, the token bytes and every later block untouched.

Building the exploit

So lets recap our game plan:

  1. Read in the test command and extract out IV (first block) to obtain IVoriginal\text{IV}_{\text{original}}
  2. Construct IVpoison\text{IV}_{\text{poison}} as defined above
  3. Prepend IVpoison\text{IV}_{\text{poison}} to the encrypted test command (less the first block, which is the IV that we are replacing) which we received in step 1 so that decrypt() will use our poisoned IV.
  4. Get flag. Profit!

We now craft the following script to implement the steps above.

Python
from pwn import *
from base64 import b64decode, b64encode

HOST = '35.200.39.68'
PORT = 16002

TESTCMD_PROMPT = 'test Command: '
CIPHERTEXT_PROMPT = 'Ciphertext:'
COMMAND_PROMPT = 'Enter your command: '
FLAG_MARKER = 'The flag is: '

COMMAND = [b'test', b'show']

poison_command = b'\x00' * 9 + xor(COMMAND[0], COMMAND[1]) + b'\x00' * 3


def chunks(l, n):
    return [l[i:i + n] for i in range(0, len(l), n)]

io = remote(HOST, PORT)

io.recvuntil(TESTCMD_PROMPT)

testcmd_encrypted = b64decode(io.recvline())

testcmd_blocks = chunks(testcmd_encrypted, 16)

iv_poison = xor(poison_command, testcmd_blocks[0])

poison_payload = b''.join([iv_poison, ] + testcmd_blocks[1:])

io.sendlineafter(COMMAND_PROMPT, b64encode(poison_payload))

io.recvuntil(FLAG_MARKER)

log.success(io.recvuntil('}').decode())

io.close()

Result

Flag: LINECTF{echidna_kawaii_and_crypto_is_difficult}