‹ Back to blog

mom can we have AES

There are two services, a homemade TLS-ish handshake, and a choice of five AES modes. We sit in the middle and relay every line ourselves. That last detail turns cipher negotiation into a suggestion.

server.py listens on port 1337 and client.py on port 1338. Most of their code implements key establishment. Both sides begin with the same list of supported modes:

cipher_suite = {
    "AES.MODE_CBC": AES.MODE_CBC,
    "AES.MODE_CTR": AES.MODE_CTR,
    "AES.MODE_EAX": AES.MODE_EAX,
    "AES.MODE_GCM": AES.MODE_GCM,
    "AES.MODE_ECB": AES.MODE_ECB,
}

The protocol resembles TLS, but the random values are tiny and every message passes through us as text. I walked through the handshake once before touching the exploit.

The client prints every supported cipher name followed by four random uppercase letters or digits.

print(*cipher_suite.keys(), sep=', ')

client_random = ''.join(
    random.SystemRandom().choice(string.ascii_uppercase + string.digits)
    for _ in range(4)
)
print(client_random)

The server reads the cipher list and client random and stores both. Real TLS random values are much larger and also carry protocol context; four characters are only enough for this challenge’s simplified derivation.

The server loads its RSA private key, hashes a pre-shared certificate and signs the hash with PKCS#1 v1.5. It prints the signature rather than the certificate itself.

private_key = RSA.import_key(open("my_credit_card_number.pem").read())
cipher_hash = SHA256.new(cert)
signature = PKCS1_v1_5.new(private_key).sign(cipher_hash)
print(signature.hex())

It then intersects the offered cipher names with its own list. Unlike TLS, which chooses one suite, this server sends the entire intersection back to the client.

selected_cipher_suite = {}
for method in cipher_suite:
    if method in client_cipher_suite:
        selected_cipher_suite[method] = cipher_suite[method]

if not selected_cipher_suite:
    exit("Honey, we have a problem.")

print(*selected_cipher_suite.keys(), sep=', ')

Finally it generates and prints its own four-character random value.

The client verifies the server signature with a pre-shared public key. Because the certificate is also embedded locally, this only proves that the other side knows the matching private key.

server_signature = bytearray.fromhex(input())
public_key = RSA.import_key(open("receiver.pem").read())
cipher_hash = SHA256.new(cert)

if not PKCS1_v1_5.new(public_key).verify(cipher_hash, server_signature):
    exit("Mom told me not to talk to strangers.")

After parsing the server’s cipher list and random value, the client creates an eight-character premaster secret, encrypts it with RSA-OAEP and prints the ciphertext.

premaster_secret = ''.join(
    random.SystemRandom().choice(string.ascii_uppercase + string.digits)
    for _ in range(8)
)

cipher_rsa = PKCS1_OAEP.new(public_key)
encrypted = cipher_rsa.encrypt(premaster_secret.encode())
print(encrypted.hex())

Both sides derive the same session material by hashing the client random, server random and premaster secret. The first sixteen ASCII bytes of the hexadecimal digest become the AES key.

session_key = SHA256.new(
    (client_random + server_random + premaster_secret).encode()
).hexdigest()

chosen_cipher_name = next(iter(selected_cipher_suite))
print(chosen_cipher_name)

cipher = AES.new(
    session_key.encode()[:16],
    cipher_suite[chosen_cipher_name]
)

The server decrypts the RSA ciphertext, repeats the derivation and checks that the requested mode was in the negotiated set. The client then sends an encrypted, padded finish message. Decrypting that value proves that both sides derived the same AES state.

# client
print(cipher.encrypt(pad(b"finish", block_size)).hex())

# server
client_finish = bytearray.fromhex(input())
finish_msg = unpad(cipher.decrypt(client_finish), block_size)
assert finish_msg == b"finish"

The server never sends its own encrypted finish. The client nevertheless waits for the literal plaintext string finish, so the relay has to provide it.

The two post-handshake oracles

The server accepts encrypted messages, decrypts them and only says whether the complete plaintext equals the flag. Guessing the entire value at once is not useful.

while True:
    client_msg = bytearray.fromhex(input())
    client_msg = unpad(cipher.decrypt(client_msg), block_size)

    if client_msg == flag:
        print("That is correct.")
    else:
        print("You are not my son.")

The client is much better. It reads a hex-encoded prefix, appends the flag and returns the encryption.

while True:
    prefix = input()
    prefix = bytearray.fromhex(prefix) if prefix else b""
    extended_flag = prefix + flag
    ciphertext = cipher.encrypt(pad(extended_flag, block_size)).hex()
    print(ciphertext)

If we can force a predictable block mode, this is exactly the shape needed for a chosen-prefix byte-at-a-time attack.

Forcing ECB during the handshake

I used pwntools to connect to both ports and relay the handshake. The first message from the client is its full cipher list. Instead of forwarding it, I send only AES.MODE_ECB to the server.

from pwn import *
import string

def get_services():
    server = remote("mom-can-we-have-aes.chal.uiuc.tf", 1337)
    client = remote("mom-can-we-have-aes.chal.uiuc.tf", 1338)

    client.recvline()  # proof-of-work line
    server.recvline()

    client.recvline()               # discard client's mode list
    server.sendline(b"AES.MODE_ECB")

    server.send(client.recvline())   # client random
    client.send(server.recvline())   # server signature
    client.send(server.recvline())   # selected modes
    client.send(server.recvline())   # server random
    server.send(client.recvline())   # encrypted premaster
    server.send(client.recvline())   # chosen mode
    server.send(client.recvline())   # encrypted finish

    client.sendline(b"finish")
    return server, client

The server believes ECB is the only shared option and reports that list to the client. The client chooses the first entry, so both sides initialize ECB with the same session key.

Recovering the flag with ECB

ECB encrypts each 16-byte block independently. Identical plaintext blocks under the same key produce identical ciphertext blocks. By choosing the prefix length, we can move the next unknown flag character to the final byte of a block.

block 1          block 2
---------------- ----------------
uiuctf{FAKEFLAG}
_______________u iuctf{FAKEFLAG}
______________ui uctf{FAKEFLAG}

Assume we know the flag prefix up to one character. First request an encryption containing only enough As to place the next unknown byte at the end of our comparison region. Then request encryptions of the same padding followed by the known flag and one guess. When the relevant ECB blocks match, the guess is correct.

The exploit compares the first two blocks, or 64 hexadecimal characters. A prefix of 31-len(known) aligns the next byte at the end of that two-block window.

def brute_force_char(client, known, alphabet):
    padding = "A" * (31 - len(known))

    client.sendline(padding.encode().hex().encode())
    target = client.recvline()[:64]

    for candidate in alphabet:
        guess = padding + known + candidate
        client.sendline(guess.encode().hex().encode())
        result = client.recvline()[:64]

        if result == target:
            return candidate

All UIUCTF flags begin with uiuctf{, so that becomes the initial known string. The loop continues until the recovered character is a closing brace.

server, client = get_services()
server.close()

flag = "uiuctf{"
alphabet = string.printable

while flag[-1] != "}":
    flag += brute_force_char(client, flag, alphabet)
    print(flag)

The complete flag appears in under two minutes:

uiuctf{AES_@_h0m3_b3_l1ke3}

There is a small request-count cleanup worth making. The target blocks for a given alignment do not change while the same session remains active. There are only sixteen possible offsets inside an AES block, so we can request those reference ciphertexts once and reuse them.

references = []
for offset in range(16):
    prefix = "A" * (15 - offset)
    client.sendline(prefix.encode().hex().encode())
    references.append(client.recvline())

The optimized character function uses at most fifteen alignment bytes and compares exactly enough ciphertext to include the guessed byte.

def brute_force_char(client, known, alphabet, references):
    prefix = "A" * (15 - len(known) % 16) + known
    compare_length = 2 * (len(prefix) + 1)
    target = references[len(known) % 16][:compare_length]

    for candidate in alphabet:
        client.sendline((prefix + candidate).encode().hex().encode())
        if client.recvline()[:compare_length] == target:
            return candidate

For this 27-character flag the optimization saves only eleven requests, which is small compared with the candidate guesses, but it makes the structure of the oracle clearer.

I also tried parallel guesses. Candidate tests are network-bound, so multiple client sessions can test different parts of the alphabet at once. Each handshake creates a different session key, which means every worker needs its own reference ciphertext for the current known prefix.

A ThreadPoolExecutor works well because the workload is I/O rather than CPU. The worker initializer creates a client, closes the unused server connection and stores the client on the current thread.

import concurrent.futures as futures
import threading
import math

def init_worker():
    server, client = get_services()
    server.close()
    client.known = None
    client.target = None
    threading.current_thread().client = client

Each task tests one character. When the known prefix changes, the worker refreshes its own target block first.

def try_character(known, candidate):
    client = threading.current_thread().client

    if client.known != known:
        padding = "A" * (31 - len(known))
        client.sendline(padding.encode().hex().encode())
        client.target = client.recvline()[:64]
        client.known = known

    guess = "A" * (31 - len(known)) + known + candidate
    client.sendline(guess.encode().hex().encode())
    result = client.recvline()[:64]
    return result == client.target, candidate

The main loop submits the alphabet, accepts the first matching future and cancels work that has not started. Cancelling the remaining futures reduced the sequential run from roughly 30 seconds to about 22.

What about the other modes?

ECB is the cleanest option, but the same oracle is worth testing under the other advertised modes.

CTR and OFB. CTR encrypts a nonce and counter and XORs that keystream with the plaintext. OFB repeatedly encrypts an initialization state and also XORs the result with plaintext. A fully known block reveals its keystream block, but the next block’s keystream remains unpredictable without the key. We can either know the plaintext and learn the mask or include an unknown flag byte, but not do both at once. Neither mode gives the equality property used by the ECB attack.

EAX and GCM. EAX combines counter-mode encryption with an authentication tag. The ciphertext portion inherits CTR’s problem, and the tag does not expose a useful byte equality oracle. GCM is another counter-based authenticated mode and hits the same obstacle here.

CBC. This one is more interesting. Before encryption, each plaintext block is XORed with the previous ciphertext block. If we know that previous block and can choose the next request’s prefix, we can cancel the chaining value and force two first blocks to enter AES with the same input.

The client keeps one cipher object alive across requests, so the final ciphertext block from one response becomes the chaining state for the next. The encrypted finish message is the first value we can use. AES blocks are 16 bytes, represented by 32 hex characters; although the challenge pads with a 32-byte block_size, the cipher’s chaining block is still 16 bytes.

# inside get_services(), after receiving the encrypted finish
client.last = finish.strip()[-32:]

A helper XORs raw prefix bytes with a hex-encoded ciphertext block:

def bytes_xor_hex(left, hex_right):
    right = bytes.fromhex(hex_right.decode())
    return bytes(a ^ b for a, b in zip(left, right))

The CBC attack still aligns the next unknown flag byte and obtains a reference response. To make a guess comparable, it removes the current chaining block from the chosen prefix and applies the chaining block that was used for the reference. After every request it saves the new final ciphertext block.

def brute_force_cbc_char(client, known, alphabet):
    padding = "A" * (31 - len(known))

    client.sendline(padding.encode().hex().encode())
    response = client.recvline()
    target = response[:64]

    prefix = (padding + known).encode()
    prefix = bytes_xor_hex(prefix[:16], client.last) + prefix[16:]
    client.last = response.strip()[-32:]

    for candidate in alphabet:
        request = bytes_xor_hex(prefix[:16], client.last)
        request += prefix[16:] + candidate.encode()

        client.sendline(request.hex().encode())
        response = client.recvline()
        client.last = response.strip()[-32:]

        if response[:64] == target:
            return candidate

This recovers the same flag in roughly the same time without the ECB downgrade. It is messier, which is why I would still use ECB on the actual service, but the CBC version makes it clear that the chosen-prefix interface is doing most of the damage.