Pwn2Win CTF 2020 - Androids Writeup

By 4cad @ TheAdditionalPayphones, 2020-06-11

This was one of the warm-up crypto challenges for the Pwn2Win 2020 CTF. This is a block cipher oracle challenge, although the heart of the challenge is really taking the time to understand the code. You connect to server.py which gives you two options:

  1. Encrypt your secret
  2. Encrypt my secret

The first option will take your base64 encoded input plaintext and give you the corresponding ciphertext. The second option gives you the encrypted flag. The important parts of code are as follows:

In [6]:
import sys
import base64
from Crypto.Cipher import AES

from secrets import flag, key1, iv1


def to_blocks(txt):
    return [txt[i*BLOCK_SIZE:(i+1)*BLOCK_SIZE] for i in range(len(txt)//BLOCK_SIZE)]


def xor(b1, b2=None):
    if isinstance(b1, list) and b2 is None:
        assert len(set([len(b) for b in b1])) == 1, 'xor() - Invalid input size'
        assert all([isinstance(b, bytes) for b in b1]), 'xor() - Invalid input type'
        x = [len(b) for b in b1][0]*b'\x00'
        for b in b1:
            x = xor(x, b)
        return x
    assert isinstance(b1, bytes) and isinstance(b2, bytes), 'xor() - Invalid input type'
    return bytes([a ^ b for a, b in zip(b1, b2)])


BUFF = 256
BLOCK_SIZE = 16
iv2 = AES.new(key1, AES.MODE_ECB).decrypt(iv1)
key2 = xor(to_blocks(flag))


def encrypt(txt, key, iv):
    global key2, iv2
    assert len(key) == BLOCK_SIZE, f'Invalid key size'
    assert len(iv) == BLOCK_SIZE, 'Invalid IV size'
    assert len(txt) % BLOCK_SIZE == 0, 'Invalid plaintext size'
    bs = len(key)
    blocks = to_blocks(txt)
    ctxt = b''
    aes = AES.new(key, AES.MODE_ECB)
    curr = iv
    for block in blocks:
        ctxt += aes.encrypt(xor(block, curr))
        curr = xor(ctxt[-bs:], block)
    iv2 = AES.new(key2, AES.MODE_ECB).decrypt(iv2)
    key2 = xor(to_blocks(ctxt))
    return str(base64.b64encode(iv+ctxt), encoding='utf8')


def enc_plaintext():
    print('Plaintext: ', end='')
    txt = base64.b64decode(input().rstrip())
    print(encrypt(txt, key1, iv1))


def enc_flag() :
    print(encrypt(flag, key2, iv2))

Most of the logic there doesn't matter in the end. The only thing that matters is the following two facts:

  1. The flag is encrypted using `key2`
  2. After every call to `encrypt()`, the value of `key2` is updated to be the resulting ciphertext

After spending some time reading the code these two points became apparent, and then it was simply a matter of getting the encrypted flag twice in a row and using the first encrypted flag as the key to decrypt the second one. Most of the time ended up being implementing a decrypt, as you can see in the following code:

In [14]:
from binascii import hexlify
BUFF = 256
BLOCK_SIZE = 16

encryptedFlag1 = 'TkK4jOlnRUoKpMQ3pbgea6mJ0ehf2jFNQajWBYOec0YitllbfcHaCak9IypFh47LubL+7EBGCI1VsOgGlpNbOA=='
encryptedFlag2 = 'stl7e5mp9Fy9FOTTuw1oOaMnIoSJGUJ8BI7lpDjiXtDbVcf2K4xgKymwpwItQ7kVfbX/Ecp0+Zcz9B2gl352Kw=='

resultBytes1 = base64.b64decode(encryptedFlag1)
resultBytes2 = base64.b64decode(encryptedFlag2)

decodedIv1 = resultBytes1[:16]
decodedCtxt1 = resultBytes1[16:]
decodedIv2 = resultBytes2[:16]
decodedCtxt2 = resultBytes2[16:]

flagKey = xor(to_blocks(decodedCtxt1))
flagCipher = decodedCtxt2
flagIv = decodedIv2

aes = AES.new(flagKey, AES.MODE_ECB)
bs = len(flagKey)
flagCipherBlocks = to_blocks(flagCipher)
decrypted = b''
curr = flagIv

for block in flagCipherBlocks :
    d = xor(aes.decrypt(block), curr)
    decrypted += d
    curr = xor(decrypted[-bs:], block)
print(decrypted)
b'CTF-BR{kn3W_7h4T_7hEr3_4r3_Pc8C_r3pe471ti0ns?!?}'