// 01
El reto y los archivos
Initialization es un challenge de categoría Crypto con dificultad Very Easy (100 puntos). La historia nos pone en una auditoría en la que detectamos tráfico hacia territorio enemigo: hay que romper el esquema de cifrado y descifrar la comunicación sospechosa. Todo el reto está en un fallo de implementación del cifrado, no en atacar AES en sí.
// DESCRIPCIÓN OFICIAL DEL RETO
«During a cyber security audit of your government's infrastructure, you discover log entries showing traffic directed towards an IP address within the enemy territory of "Oumara"... you analyze the encryption scheme with the goal of breaking it and decrypting the suspicious communication.»
El reto entrega un initialization.zip que, al descomprimir, contiene tres ficheros:
| ARCHIVO | CONTENIDO |
|---|---|
| source.py | El script que generó el cifrado (el «algoritmo» vulnerable) |
| messages.txt | Los 4 mensajes en claro, con la flag censurada en uno de ellos |
| output.txt | Los 4 mensajes ya cifrados, en hexadecimal |
messages.txt es la clave de todo: conocemos 3 de los 4 textos planos; solo el tercero —la flag— está censurado. Ese dato es lo que permite el ataque.
source.py cifra cada mensaje con AES en modo CTR.
El propio autor deja un comentario tranquilizador —# nonce reuse : avoided!—
que resulta ser falso. Estas son las dos piezas que importan:
// 02
La vulnerabilidad
La línea keys = [[b'\x00']*16] * len(MSG) es un error clásico de Python:
multiplicar una lista que contiene otra lista no crea copias independientes,
crea varias referencias al mismo objeto. Es decir, keys[0],
keys[1], keys[2] y keys[3] apuntan
a la misma lista en memoria.
Cuando el bucle rellena keys[i][j] = os.urandom(1), en realidad sobrescribe una y
otra vez la misma lista de 16 bytes. Al terminar, los 4 «keys» son idénticos:
KEYS[0] == KEYS[1] == KEYS[2] == KEYS[3].
// CONSECUENCIA 1
Los 4 mensajes se cifran con la misma clave AES. El comentario
# nonce reuse : avoided! no se cumple.
Counter.new(block_size) se crea sin initial_value, de
modo que cada contador arranca en 0. Y como se crea uno nuevo por mensaje (en
vez de uno solo que se vaya incrementando), los 4 cifrados empiezan en el mismo punto del
contador.
En AES-CTR el keystream se genera como AES_E(key, counter) — depende solo de la clave y del contador, nunca del texto plano. Con la misma clave y el mismo contador inicial, el keystream es idéntico para los 4 mensajes:
Esto es un one-time pad reutilizado (many-time pad), uno de los errores más conocidos: si el mismo keystream cifra dos o más mensajes, un XOR entre dos ciphertexts cancela el keystream y deja los textos planos enredados entre sí:
// LA PALANCA
Como conocemos 3 de los 4 mensajes en claro, podemos despejar el keystream
directamente (KEYSTREAM = CT_i XOR PAD(MSG_i)) y usarlo para descifrar el cuarto:
la flag. No hace falta atacar AES ni conocer la clave.
// 03
Explotación
- 1. Recuperar el keystream con los mensajes conocidos:
KEYSTREAM = CT_i XOR PAD(MSG_i). - 2. Aplicarlo al ciphertext de la flag:
PAD(FLAG) = CT_flag XOR KEYSTREAM. - 3. Quitar el relleno PKCS7 final (el último byte dice cuántos bytes sobran).
El PAD es el mismo PKCS7 de 16 bytes que usa source.py mediante
Crypto.Util.Padding.pad.
Creamos el exploit con nano solve.py. Reconstruye el keystream con los mensajes 0, 1 y 3 (conocidos) y lo aplica al ciphertext del mensaje 2 (la flag):
// FLAG DEL RETO
El keystream queda cubierto al 100 % (64/64 bytes) porque el mensaje 3 es más largo que la flag, así que un solo texto conocido ya basta para descifrarla por completo. En esta versión publicada el valor va censurado: es la respuesta oficial del challenge y publicarla arruina el reto a quien lo esté resolviendo. La cadena completa queda documentada paso a paso.
// LECCIÓN
En cualquier cifrado de flujo (AES-CTR, OFB, RC4, one-time pad…) el keystream
nunca debe reutilizarse con la misma clave: si se repite, conocer un solo
texto plano revela cualquier otro cifrado con ese keystream. Y el bug de fondo fue de
programación —[[0]*16] * n crea referencias compartidas, no copias—, un recordatorio
de que los fallos criptográficos reales suelen nacer en la implementación, no en las
matemáticas. Un simple assert KEYS[0] != KEYS[1] lo habría cazado.
// 04
Referencia de Comandos
- -l — lista el contenido sin extraer
- -d <dir> — extrae en un directorio concreto
- source.py — revela el aliasing y el contador a 0
- messages.txt — 3 textos conocidos + flag censurada
- output.txt — 4 ciphertexts en hex
- bytes.fromhex() — pasa los ciphertexts de hex a bytes
- a ^ b — XOR byte a byte (recupera/aplica keystream)
- pkcs7_pad() — mismo padding de 16 bytes que source.py
- [:-pad] — recorta el relleno PKCS7 final
// 05
Glosario
AES_E(key, counter)—
y lo combina con el texto plano mediante XOR. Su seguridad depende por completo de que la pareja
(clave, contador) no se repita nunca.
MSG_a XOR MSG_b, que se puede
resolver si se conoce uno de los dos textos.
Counter.new() sin initial_value arranca
siempre en 0— rompe esa unicidad y es la causa directa del keystream repetido.
A XOR B XOR B = A. Esa propiedad permite despejar el keystream
(KEYSTREAM = CT XOR PT) cuando se conoce un par texto plano / cifrado, y luego
aplicarlo a otro ciphertext.
pkcs7_pad() y al final se
recorta con [:-pad].
[[x]*16] * n no crea n listas independientes, sino n referencias a
la misma lista. Modificar una modifica todas. Es el bug que hace que las 4
«claves» sean en realidad la misma. La forma correcta sería una comprensión:
[[x]*16 for _ in range(n)].