INITIALIZATION

// AES-CTR con keystream reutilizado (bug de aliasing + contador a 0) → many-time pad → XOR

● MUY FÁCIL HACKTHEBOX CHALLENGE CRYPTO AES-CTR KEYSTREAM REUSE 📅 Octubre 2026 · Por Brandeiks

El reto y los archivos

1
CONTEXTO DEL CHALLENGE

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.»

2
LOS TRES ARCHIVOS

El reto entrega un initialization.zip que, al descomprimir, contiene tres ficheros:

UNZIP
$ unzip initialization.zip
$ cd crypto_initialization
$ ls
messages.txt output.txt source.py
ARCHIVO CONTENIDO
source.pyEl script que generó el cifrado (el «algoritmo» vulnerable)
messages.txtLos 4 mensajes en claro, con la flag censurada en uno de ellos
output.txtLos 4 mensajes ya cifrados, en hexadecimal
3
LOS MENSAJES EN CLARO

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.

[
    'This is some public information that can be read out loud.',
    'No one can crack our encryption algorithm.',
    'HTB{????????????????????????????????????????}',
    'Secret information is encrypted with Advanced Encryption Standards.',
]
4
EL CÓDIGO FUENTE DEL CIFRADO

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:

def generate_encryption_keys(self):
    keys = [[b'\x00']*16] * len(MSG)
    for i in range(len(keys)):
        for j in range(len(keys[i])):
            keys[i][j] = os.urandom(1)
    return keys
 
self.CTRs = [Counter.new(block_size) for i in range(len(MSG))] # nonce reuse : avoided!

La vulnerabilidad

5
BUG 1 — ALIASING EN LAS CLAVES

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.

6
BUG 2 — EL CONTADOR ARRANCA EN 0

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.

7
RESULTADO — MANY-TIME PAD

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:

CT_i = PAD(MSG_i) XOR KEYSTREAM (mismo KEYSTREAM para todo i)

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í:

CT_a XOR CT_b = PAD(MSG_a) XOR PAD(MSG_b)

// 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.

Explotación

8
LA IDEA EN TRES PASOS
  • 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.

9
EL SCRIPT solve.py

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):

#!/usr/bin/env python3
messages = [
    'This is some public information that can be read out loud.',
    'No one can crack our encryption algorithm.',
    'HTB{????...????}', # el mensaje 2 es la flag
    'Secret information is encrypted with Advanced Encryption Standards.',
]
 
with open('output.txt') as f:
    cts = [bytes.fromhex(l.strip()) for l in f if l.strip()]
 
def pkcs7_pad(data, block=16):
    padlen = block - (len(data) % block)
    return data + bytes([padlen]) * padlen
 
target_len = len(cts[2]) # longitud del ciphertext de la flag
ks = bytearray(target_len)
covered = [False] * target_len
 
# keystream reutilizado a partir de los mensajes 0, 1 y 3
for idx in [0, 1, 3]:
    pt = pkcs7_pad(messages[idx].encode(), 16)
    for i, b in enumerate(bytes(a ^ c for a, c in zip(cts[idx], pt))):
        if i < target_len and not covered[i]:
            ks[i], covered[i] = b, True
 
flag_padded = bytes(a ^ c for a, c in zip(cts[2], ks))
flag = flag_padded[:-flag_padded[-1]] # quitar PKCS7
print('[+] Flag:', flag.decode(errors='replace'))
10
EJECUCIÓN
EXPLOIT
$ python3 solve.py
[*] Bytes de keystream cubiertos: 64/64
[+] Flag: HTB{...} ← flag del reto (omitida en la versión publicada)

// 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.

Referencia de Comandos

unzip
unzip initialization.zip
Descomprime el paquete del reto. Dentro está el directorio crypto_initialization con los tres archivos (source.py, messages.txt, output.txt).
  • -l — lista el contenido sin extraer
  • -d <dir> — extrae en un directorio concreto
cat
cat source.py messages.txt output.txt
Lee los tres archivos: el código del cifrado (para detectar el bug), los mensajes en claro conocidos y los ciphertexts en hexadecimal.
  • source.py — revela el aliasing y el contador a 0
  • messages.txt — 3 textos conocidos + flag censurada
  • output.txt — 4 ciphertexts en hex
python3 solve.py
python3 solve.py
Ejecuta el exploit: recupera el keystream con XOR entre cada ciphertext conocido y su texto plano (con padding PKCS7), lo aplica al ciphertext de la flag y quita el relleno.
  • 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
11
SECUENCIA COMPLETA
RESUMEN
# 1. descomprimir y entrar
$ unzip initialization.zip && cd crypto_initialization
# 2. revisar el código y los datos
$ cat source.py messages.txt output.txt
# 3. escribir el exploit
$ nano solve.py
# 4. descifrar la flag
$ python3 solve.py

Glosario

AES-CTR (modo contador)
Modo de operación que convierte AES, un cifrado de bloque, en un cifrado de flujo. Genera un keystream cifrando valores sucesivos de un contador —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.
Keystream
El «chorro» de bytes pseudoaleatorios que un cifrado de flujo combina con el texto plano. En AES-CTR solo depende de la clave y del contador, no del mensaje. Por eso, con la misma clave y el mismo contador inicial, cifrar dos mensajes distintos usa exactamente el mismo keystream.
One-time pad / many-time pad
El one-time pad es seguro solo si el keystream es verdaderamente aleatorio y se usa una única vez. Reutilizarlo lo convierte en un many-time pad: el XOR de dos ciphertexts elimina el keystream y deja MSG_a XOR MSG_b, que se puede resolver si se conoce uno de los dos textos.
Nonce / contador y su reutilización
El nonce (o el contador en CTR) es el valor que hace único cada cifrado bajo una misma clave. Reiniciarlo —aquí, Counter.new() sin initial_value arranca siempre en 0— rompe esa unicidad y es la causa directa del keystream repetido.
XOR
Operación «o exclusivo», base de los cifrados de flujo. Es reversible: 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.
Padding PKCS7
Esquema de relleno que completa el mensaje hasta un múltiplo del tamaño de bloque (16 bytes en AES). Rellena con N bytes de valor N, así que el último byte indica cuántos quitar. En el exploit se replica con pkcs7_pad() y al final se recorta con [:-pad].
Aliasing de listas en Python
[[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)].
Texto plano conocido (known-plaintext)
Ataque en el que el analista dispone de pares texto plano / texto cifrado. Contra un keystream reutilizado es demoledor: cada byte conocido del mensaje revela el byte correspondiente del keystream, que sirve para descifrar cualquier otro mensaje cifrado con él.

// RELACIONADOS POR ETIQUETAS