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.