En la lección anterior ciframos exactamente 16 bytes con AES y nos topamos con la pared: un historial real mide kilobytes, y una máquina que solo procesa bloques de 16 bytes necesita instrucciones sobre cómo encadenarlos. Esas instrucciones son el modo de operación, y son la diferencia entre un cifrado seguro y una filtración de datos con AES perfectamente intacto por debajo. En esta lección vas a ver — con código, sobre un historial de MediNube — por qué el modo ECB es un desastre estructural (el famoso "pingüino"), cómo lo arreglan CBC (con su IV y su relleno) y CTR (convirtiendo AES en un cifrado en flujo), y una catástrofe que reaparecerá en todo el curso: reutilizar un nonce. Al final tendrás una regla que no se negocia y una pieza que todavía falta: ninguno de estos modos detecta manipulaciones. Eso lo resolverá la lección 02-03.
Contenido
- Por qué un cifrado en bloque necesita un modo
- ECB: el modo que no debes usar (y el pingüino)
- CBC: encadenamiento, IV y relleno PKCS7
- CTR: el contador que convierte AES en cifrado en flujo
- La catástrofe del nonce reutilizado
- Tabla comparativa y la regla de oro del IV/nonce
Por qué un cifrado en bloque necesita un modo
AES es una función que transforma 16 bytes en 16 bytes con una clave. Nada más. Para cifrar un mensaje de cualquier otro tamaño hay que responder a dos preguntas:
- ¿Cómo troceo el mensaje y encadeno los bloques? Si el mensaje mide 1600 bytes, son 100 bloques: ¿los ciframos independientes? ¿los enlazamos? Esa decisión es el modo de operación.
- ¿Qué hago con el último trozo si no llega a 16 bytes? Un mensaje de 22 bytes tiene un bloque completo y 6 bytes sueltos. Rellenar ese hueco es el padding (relleno), y no todos los modos lo necesitan.
flowchart LR
M[Mensaje de N bytes] --> S[Trocear en bloques de 16]
S --> B1[Bloque 1]
S --> B2[Bloque 2]
S --> B3[... Bloque k]
B1 & B2 & B3 --> MODE{Modo de operación:\ncómo se combinan}
MODE --> C[Texto cifrado]
La elección del modo no cambia AES: cambia cómo se orquestan las llamadas a AES. Y ahí es donde se gana o se pierde la seguridad. Vamos a verlo.
ECB: el modo que no debes usar (y el pingüino)
ECB (Electronic Codebook) es el modo más ingenuo: cifra cada bloque de 16 bytes de forma independiente, con la misma clave y sin ninguna relación entre ellos.
flowchart LR
subgraph ECB
P1[Bloque 1] --> E1[AES] --> Cc1[Cifrado 1]
P2[Bloque 2] --> E2[AES] --> Cc2[Cifrado 2]
P3[Bloque 3] --> E3[AES] --> Cc3[Cifrado 3]
end
El fallo es estructural y demoledor: como cada bloque se cifra igual, bloques de texto claro idénticos producen bloques de cifrado idénticos. El cifrado se convierte en una tabla de correspondencias que preserva los patrones del original. La ilustración clásica es el "pingüino de Linux" (Tux): si cifras la imagen del pingüino con ECB, sigues viendo el pingüino, porque las grandes zonas del mismo color producen los mismos bloques cifrados. AES está intacto; el modo lo delata.
Vamos a reproducir el efecto sobre datos de MediNube. Los historiales tienen estructura repetitiva (campos que se repiten entre pacientes), y ECB la filtra:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clave = os.urandom(32)
# Un "historial" con estructura repetitiva: el mismo bloque de 16 bytes
# aparece tres veces (imagina un campo de plantilla repetido en el registro).
bloque_repetido = b"CLINICA: SOL----" # exactamente 16 bytes
datos = bloque_repetido * 3 + b"PACIENTE:ANA----" # 4 bloques, el 1-2-3 iguales
cifrador = Cipher(algorithms.AES(clave), modes.ECB()).encryptor()
cifrado = cifrador.update(datos) + cifrador.finalize()
# Troceamos el cifrado en bloques de 16 y buscamos repeticiones.
bloques = [cifrado[i:i+16] for i in range(0, len(cifrado), 16)]
for i, b in enumerate(bloques):
print(i, b.hex())
repetidos = len(bloques) - len(set(bloques))
print(f"\nBloques cifrados repetidos: {repetidos}") # -> 2Salida: los tres primeros bloques cifrados son idénticos byte a byte, y el contador detecta 2 repeticiones. Sin conocer la clave ni descifrar nada, un atacante ya sabe que "hay tres cosas iguales al principio" — la estructura del historial se ha filtrado. En datos reales esto revela plantillas, valores repetidos (mismo diagnóstico, mismo grupo sanguíneo entre pacientes), longitudes... información valiosísima. La regla es tajante:
Nunca uses ECB para cifrar datos. Es el modo que solo sirve como pieza interna de construcciones más complejas, jamás directamente.
¿Cómo se arregla? Haciendo que bloques iguales se cifren distinto. Hay dos estrategias, y dan lugar a CBC y CTR.
CBC: encadenamiento, IV y relleno PKCS7
CBC (Cipher Block Chaining) rompe la repetición encadenando los bloques: antes de cifrar cada bloque, se le aplica XOR con el cifrado del bloque anterior. Así, el resultado de cada bloque depende de todos los anteriores; dos bloques iguales en el texto claro producen cifrados distintos porque llegan con "contexto" diferente.
flowchart LR
IV[IV aleatorio] -->|XOR| X1
P1[Bloque 1] --> X1((XOR)) --> E1[AES] --> C1[Cifrado 1]
C1 -->|XOR| X2
P2[Bloque 2] --> X2((XOR)) --> E2[AES] --> C2[Cifrado 2]
C2 -->|XOR| X3
P3[Bloque 3] --> X3((XOR)) --> E3[AES] --> C3[Cifrado 3]
Pero el primer bloque no tiene "anterior". Ahí entra el IV (Initialization Vector, vector de inicialización): un bloque de 16 bytes que hace de "cifrado del bloque cero". Sus requisitos:
- Aleatorio e impredecible para cada mensaje: sale del CSPRNG (
os.urandom(16)). Si el IV fuera fijo o predecible, dos mensajes que empiezan igual volverían a delatarse. - Público: se guarda/transmite junto al cifrado, en claro. No es un secreto; su valor está en ser irrepetible e impredecible.
Y como CBC cifra bloque a bloque, el mensaje debe ser múltiplo exacto de 16 bytes. Casi nunca lo es, así que se rellena el último bloque. El estándar es PKCS7: rellena con bytes cuyo valor es el número de bytes de relleno añadidos. Si faltan 5 bytes para completar el bloque, añade 05 05 05 05 05; si el mensaje ya era múltiplo de 16, añade un bloque entero de 10 10 ... 10 (16 en hexadecimal es 0x10), para que al descifrar siempre se sepa cuánto quitar sin ambigüedad.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
clave = os.urandom(32)
iv = os.urandom(16) # IV aleatorio de 16 bytes, uno por mensaje
mensaje = "HISTORIAL DE ANA PEREZ".encode("utf-8") # 22 bytes, NO múltiplo de 16
# 1. Rellenar hasta múltiplo del tamaño de bloque (128 bits = 16 bytes).
padder = padding.PKCS7(128).padder()
rellenado = padder.update(mensaje) + padder.finalize()
print(len(rellenado)) # 32: se rellenó de 22 a 32 (2 bloques)
# 2. Cifrar con CBC, pasando el IV.
cifrador = Cipher(algorithms.AES(clave), modes.CBC(iv)).encryptor()
cifrado = cifrador.update(rellenado) + cifrador.finalize()
# 3. Para descifrar hay que conservar el IV (se guarda junto al cifrado).
descifrador = Cipher(algorithms.AES(clave), modes.CBC(iv)).decryptor()
rellenado2 = descifrador.update(cifrado) + descifrador.finalize()
# 4. Quitar el relleno.
unpadder = padding.PKCS7(128).unpadder()
recuperado = unpadder.update(rellenado2) + unpadder.finalize()
print(recuperado.decode("utf-8")) # HISTORIAL DE ANA PEREZPuntos clave del código:
- El IV se genera con
os.urandom(16)en cada cifrado y se guarda junto al resultado. Sin el IV correcto no se descifra. padding.PKCS7(128)— el128son los bits del bloque de AES (16 bytes). Elpadderañade el relleno; elunpadderlo retira y valida que sea coherente.- CBC resuelve el problema de ECB: bloques iguales dan cifrados distintos, y el mismo mensaje con distinto IV da un cifrado totalmente distinto.
Ahora bien, CBC tiene una fragilidad histórica que es la razón por la que hoy preferimos AEAD (lección 02-03): el proceso de "quitar y validar el relleno" puede convertirse en un oráculo para el atacante. Si el sistema reacciona de forma distinguible ante un relleno válido frente a uno inválido (un error, un tiempo distinto), un atacante puede, enviando cifrados manipulados y observando esas reacciones, descifrar los datos sin la clave. Es el padding oracle attack. No vamos a desarrollar el ataque aquí — quédate con la moraleja: CBC "a pelo" es peligroso de manejar correctamente, y la solución moderna no es parchearlo, sino usar cifrado autenticado.
CTR: el contador que convierte AES en cifrado en flujo
CTR (Counter) usa otra idea completamente distinta: en lugar de cifrar el mensaje, cifra un contador para generar un keystream, y luego hace XOR de ese keystream con el mensaje. Es decir, convierte AES (cifrado en bloque) en un cifrado en flujo, como los que vimos en 02-01.
flowchart LR
N0[nonce+contador 0] --> AE0[AES] --> K0[keystream 0]
N1[nonce+contador 1] --> AE1[AES] --> K1[keystream 1]
K0 -->|XOR| P0[Bloque 0] --> C0[Cifrado 0]
K1 -->|XOR| P1[Bloque 1] --> C1[Cifrado 1]
Cómo funciona: se construye un bloque de entrada juntando un nonce (valor único por mensaje) con un contador que empieza en 0 y va incrementándose (0, 1, 2, ...). Cada uno de esos bloques nonce||contador se cifra con AES, y el resultado es el keystream que se combina por XOR con el mensaje. Ventajas notables:
- No necesita padding: como es XOR byte a byte, el cifrado mide exactamente lo mismo que el mensaje. Adiós al padding oracle de CBC.
- Paralelizable: cada bloque del keystream se calcula independientemente (basta conocer su posición), así que se puede cifrar/descifrar en paralelo o acceder al azar a mitad de un fichero.
- Cifrar y descifrar son la misma operación: XOR con el mismo keystream (descifrar = volver a hacer XOR).
En pyca/cryptography, CTR recibe el bloque inicial completo (nonce + contador) de 16 bytes:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clave = os.urandom(32)
nonce = os.urandom(16) # bloque inicial (nonce||contador), único por mensaje
mensaje = "HISTORIAL DE ANA PEREZ".encode("utf-8")
cifrador = Cipher(algorithms.AES(clave), modes.CTR(nonce)).encryptor()
cifrado = cifrador.update(mensaje) + cifrador.finalize()
print(len(cifrado)) # 22: MISMA longitud que el mensaje, sin padding
descifrador = Cipher(algorithms.AES(clave), modes.CTR(nonce)).decryptor()
recuperado = descifrador.update(cifrado) + descifrador.finalize()
print(recuperado.decode("utf-8")) # HISTORIAL DE ANA PEREZCTR es elegante y rápido, pero arrastra un peligro mortal que comparte con todos los cifrados en flujo (ChaCha20 incluido). Vamos a verlo con código, porque es una de las lecciones que más caras salen cuando se ignora.
La catástrofe del nonce reutilizado
En cualquier cifrado en flujo, el cifrado es cifrado = mensaje XOR keystream, y el keystream depende solo de la clave y del nonce. Por tanto, si ciframos dos mensajes con la misma clave y el mismo nonce, ambos usan el mismo keystream. Y ahí un atacante hace magia sin conocer la clave:
El keystream se cancela y el atacante obtiene el XOR de los dos mensajes en claro, sin la clave para nada. Eso filtra muchísimo, y si conoce (o adivina) parte de uno, recupera la parte correspondiente del otro. Demostración sobre dos historiales de MediNube cifrados con el mismo nonce por error:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clave = os.urandom(32)
nonce = os.urandom(16) # ⚠️ EL MISMO nonce para dos mensajes: el error fatal
m1 = b"PACIENTE: Ana Perez GRUPO: 0+"
m2 = b"PACIENTE: Juan Prats GRUPO: AB"
def cifra(m):
c = Cipher(algorithms.AES(clave), modes.CTR(nonce)).encryptor()
return c.update(m) + c.finalize()
c1, c2 = cifra(m1), cifra(m2)
# El atacante SOLO ve c1 y c2. Calcula su XOR:
xor_cifrados = bytes(a ^ b for a, b in zip(c1, c2))
xor_claros = bytes(a ^ b for a, b in zip(m1, m2))
print(xor_cifrados == xor_claros) # True: C1 XOR C2 == M1 XOR M2, el keystream desapareció
# Y si el atacante conoce/adivina m1 (p. ej. porque conoce la plantilla),
# despeja m2 por completo, sin la clave:
m2_recuperado = bytes(a ^ b for a, b in zip(xor_cifrados, m1))
print(m2_recuperado) # b'PACIENTE: Juan Prats GRUPO: AB'Con solo los dos cifrados y una suposición razonable sobre uno de los mensajes (la plantilla del historial es conocida: regla de Kerckhoffs, el enemigo conoce el sistema), el atacante reconstruye el otro entero. La clave AES-256 no ha servido de nada. Esta es una de las formas más frecuentes y catastróficas de romper criptografía por mal uso, y no es exclusiva de CTR: le pasa exactamente igual a ChaCha20 y a AES-GCM (02-03). De ahí la regla que cierra la lección.
Tabla comparativa y la regla de oro del IV/nonce
| ECB | CBC | CTR | |
|---|---|---|---|
| Idea | Cada bloque independiente | Encadena con el cifrado anterior | Cifra un contador, XOR con el mensaje |
| ¿IV / nonce? | Ninguno | IV aleatorio e impredecible (16 B) | nonce+contador único (16 B) |
| ¿Padding? | Sí | Sí (PKCS7) | No |
| Longitud del cifrado | Múltiplo de 16 | Múltiplo de 16 | Igual que el mensaje |
| Paralelizable | Sí | Cifrado no, descifrado sí | Sí (cifrar y descifrar) |
| Fallo característico | Filtra patrones (pingüino) | Padding oracle | Nonce reutilizado |
| Protege la integridad | No | No | No |
| Recomendación | Nunca | Solo con integridad añadida; hoy, evitar | Solo con integridad añadida (es la base de GCM) |
Dos conclusiones que gobiernan todo lo que sigue:
Regla de oro: nunca reutilices la pareja clave + IV/nonce. Con la misma clave, cada mensaje necesita su propio IV/nonce único (y, en CBC, impredecible). Repetirlo rompe la confidencialidad de golpe, sin necesidad de romper el algoritmo.
Y el gran cabo suelto, visible en la última fila de la tabla: ninguno de estos modos protege la integridad. Nada impide que un atacante modifique el cifrado y que al descifrar salga un dato alterado sin que nadie lo note. Con CTR, además, es escalofriantemente fácil (cambiar un bit del cifrado cambia exactamente ese bit del mensaje). Resolver esto — cifrar y detectar manipulaciones en una sola operación — es el salto a AEAD que damos en la próxima lección, y es la construcción con la que por fin ciframos los historiales de MediNube como es debido.
Errores Comunes y Consejos
- Usar ECB "porque es el más simple". Es la trampa número uno. Si tu código llama a
modes.ECB()para cifrar datos reales, es un bug de seguridad. En este curso ECB solo apareció como pieza didáctica de un bloque suelto. - Reutilizar el IV/nonce. El error más caro del módulo. Un contador global mal gestionado, un IV "fijo para simplificar", un nonce derivado de la hora con poca resolución... todos acaban en reutilización. Genera el IV/nonce con el CSPRNG en cada cifrado y guárdalo junto al dato.
- Creer que el IV es secreto. No lo es: es público. Su virtud es ser irrepetible (y, en CBC, impredecible), no oculto.
- Confiar en CBC "a pelo". El padding oracle es real y sutil. La respuesta profesional no es programar la validación del padding con más cuidado, sino usar AEAD (02-03).
- Pensar que cifrar = proteger. Cifrar da confidencialidad, no integridad. Un cifrado sin autenticación es manipulable. Ese es el puente a la próxima lección.
- Olvidar guardar el IV/nonce. Sin él no se puede descifrar. Forma parte del dato cifrado y debe almacenarse con él (lo formalizaremos en el formato
v1de 02-03).
Ejercicios
-
Detector de ECB. Escribe una función
parece_ecb(cifrado: bytes) -> boolque, troceando el cifrado en bloques de 16 bytes, devuelvaTruesi hay algún bloque repetido. Pruébala con el cifrado del ejemplo de ECB de la lección y con uno hecho en CTR sobre los mismos datos. ¿Qué observas? -
El IV importa. Cifra el mismo mensaje dos veces en CBC, primero con el mismo IV y luego con dos IV distintos. Compara los cifrados en ambos casos y explica el resultado.
-
Bit-flipping en CTR (aperitivo de 02-03). Cifra en CTR el mensaje
b"SALDO: 000010 EUR". Sin conocer la clave, manipula el byte del cifrado que corresponde a un dígito del importe (haciéndole XOR con un valor) para que, al descifrar, el importe cambie. ¿Qué demuestra esto sobre la integridad?
Soluciones
def parece_ecb(cifrado: bytes) -> bool:
bloques = [cifrado[i:i+16] for i in range(0, len(cifrado), 16)]
return len(bloques) != len(set(bloques))Con el cifrado ECB de datos repetitivos devuelve True (hay bloques repetidos → filtración detectable). Con CTR sobre los mismos datos devuelve False: aunque los bloques del mensaje se repitan, cada uno se cifra con una posición distinta del keystream, así que los bloques cifrados son todos distintos. Es, en miniatura, el test que usan los analistas para detectar cifrados ECB.
-
Con el mismo IV, los dos cifrados salen idénticos (mismo mensaje + mismo IV + misma clave = mismo resultado; eso ya filtra que los mensajes son iguales). Con IV distintos, los dos cifrados son completamente diferentes pese a cifrar lo mismo. Esa es justo la función del IV: garantizar que cifrar el mismo dato dos veces no produzca la misma salida.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clave, nonce = os.urandom(32), os.urandom(16)
m = b"SALDO: 000010 EUR"
c = Cipher(algorithms.AES(clave), modes.CTR(nonce)).encryptor()
cifrado = bytearray(c.update(m) + c.finalize())
# Posición del último '1' del importe (índice 12). En CTR: cifrado = m XOR keystream,
# así que cifrado[i] XOR X descifra a m[i] XOR X. Cambiamos '1'(0x31) por '9'(0x39):
cifrado[12] ^= (ord('1') ^ ord('9'))
d = Cipher(algorithms.AES(clave), modes.CTR(nonce)).decryptor()
print((d.update(bytes(cifrado)) + d.finalize())) # b'SALDO: 000090 EUR'Sin la clave, hemos convertido un saldo de 10 en 90 con precisión quirúrgica. Demuestra que CTR (y todo cifrado en flujo) es maleable: la confidencialidad no implica integridad. Detectar y rechazar esta manipulación es exactamente lo que aporta el cifrado autenticado de la siguiente lección.
Conclusión
Ya sabes por qué AES necesita un modo de operación y qué hace cada uno: ECB filtra patrones y no se usa jamás (lo viste con tus propios ojos sobre un historial repetitivo); CBC encadena bloques con un IV aleatorio e impredecible y rellena con PKCS7, pero su padding oracle lo hace peligroso de manejar; CTR convierte AES en cifrado en flujo, sin padding y paralelizable, pero comparte con ChaCha20 la catástrofe del nonce reutilizado, que demostramos filtra los mensajes por completo. Te llevas la regla que no se negocia — nunca repitas la pareja clave + IV/nonce — y un hueco enorme que ninguno de estos modos cubre: la integridad. El bit-flipping del último ejercicio lo deja clarísimo — se puede alterar un cifrado sin la clave y sin que nadie lo detecte. La solución no es apilar parches sobre CBC o CTR, sino dar el salto al cifrado autenticado. En la próxima lección, 02-03: Cifrado Autenticado (AEAD), unimos confidencialidad e integridad en una sola operación con AES-GCM y ChaCha20-Poly1305 — y, por fin, saldamos la deuda del curso cifrando los historiales de MediNube en disco con el formato versionado v1. Nos vemos allí.
Curso de Criptografía Aplicada
Módulo 1: Fundamentos de la Criptografía
- Qué es la criptografía y para qué sirve
- Codificación, ofuscación y cifrado
- Aleatoriedad y entropía
- El principio de Kerckhoffs y las reglas de oro
Módulo 2: Criptografía Simétrica
- Cifrado simétrico: AES y ChaCha20
- Modos de operación
- Cifrado autenticado (AEAD)
- Derivación de claves (KDF)
Módulo 3: Hashes, MAC y Contraseñas
- Funciones hash criptográficas
- Autenticación de mensajes con HMAC
- Almacenamiento seguro de contraseñas
Módulo 4: Criptografía Asimétrica
- Fundamentos de clave pública y RSA
- Criptografía de curva elíptica
- Firmas digitales
- Intercambio de claves: Diffie-Hellman
- Cifrado híbrido
Módulo 5: PKI, Certificados y TLS
- Certificados X.509 y autoridades de certificación
- TLS en la práctica
- Gestión del ciclo de vida de certificados
