Hash, cifrado y codificación: diferencias medidas

Seguridad 1: decodifica Base64 en una línea, calcula SHA-256, cambia un bit y mira cambiar 128, y mide por qué MD5 no sirve para contraseñas.

Serie: curso de seguridad básica 1/6 (principiante). Esta clase es para ti si escuchaste "hashéalo", "está cifrado" y "lo codificamos en Base64" como si fueran lo mismo. Al terminar vas a distinguir las cuatro herramientas, ejecutar cada una en menos de un minuto y explicar con números por qué un hash rápido no sirve para guardar contraseñas.

Si haces backends como freelancer o acabas de salir de un bootcamp, tarde o temprano un cliente te va a preguntar si "los datos están encriptados". El GDPR/RGPD europeo y las leyes de protección de datos de varios países de Latinoamérica piden, en términos generales, medidas de seguridad adecuadas para los datos personales, y una filtración de contraseñas mal guardadas es justo el tipo de incidente que esas leyes quieren evitar. (Esto no es asesoría legal: consulta a un especialista para tu caso.) En la práctica, muchos de esos incidentes no vienen de ataques sofisticados sino de confundir codificación, hash, MAC y cifrado.

Todo lo que sigue se ejecutó en una Mac con Apple Silicon (M1 Pro) con Python 3.9.6, PHP 8.5.6, OpenSSL 4.1.0 y las herramientas shasum y base64 que trae macOS. No se instaló ninguna librería. Las rutas se acortaron a ~/demo/.

Cuatro palabras que no son lo mismo

Primero el mapa completo. Después ejecutamos cada una.

Herramienta Ejemplo ¿Necesita clave? ¿Reversible? Contra qué protege
Codificación Base64, hex, UTF-8 No Sí, cualquiera Nada. Solo cambia el formato
Hash SHA-256, BLAKE2 No No (un solo sentido) Daños accidentales; obtener la entrada a partir de la salida
MAC HMAC-SHA256 Sí, un secreto compartido No Falsificación: que alguien sin la clave cambie los datos
Cifrado AES-GCM Sí Sí, con la clave Lectura: que alguien sin la clave vea los datos

Dos términos aparecen por primera vez, así que en palabras simples: una función hash convierte cualquier entrada en una huella de tamaño fijo (32 bytes en SHA-256) y no se puede ejecutar al revés. Un MAC (código de autenticación de mensajes) es un hash que además recibe una clave secreta, así que solo quien conoce la clave puede generar una huella que pase la verificación.

Diagrama de decisión con tres preguntas. Primera: ¿es una contraseña que solo verificarás después? Si sí, usa un hash lento de contraseñas como bcrypt o Argon2id. Si no: ¿necesitas recuperar el dato original después? Si sí y debe ser secreto, usa cifrado como AES-GCM con una clave; si sí pero no necesita ser secreto, basta una codificación como Base64, que solo cambia el formato. Si no: ¿el atacante puede cambiar también la huella? Si sí, usa un MAC como HMAC-SHA256 con una clave secreta; si no, basta un hash como SHA-256.
¿Cuál de las cuatro herramientas necesitas? La codificación solo cambia el formato, un hash es la huella de los datos, un MAC prueba quién los escribió y el cifrado los oculta a quien no tenga la clave. Las contraseñas usan su propio hash lento (clase 2).

Base64 no es cifrado: se decodifica en una línea

Base64 convierte cada 3 bytes en 4 caracteres de A–Z a–z 0–9 + /. Existe para que los datos binarios pasen por lugares que solo aceptan texto: correo, JSON, URLs, headers HTTP. Este es el tipo de "secreto" que alguien pega en un archivo de configuración porque se ve revuelto:

import base64
token = base64.b64encode(b"maria:Verano2026!").decode()
print("encoded :", token)
print("decoded :", base64.b64decode(token))
encoded : bWFyaWE6VmVyYW5vMjAyNiE=
decoded : b'maria:Verano2026!'

La terminal hace lo mismo sin Python, y cualquiera que vea el texto puede hacerlo:

$ echo 'bWFyaWE6VmVyYW5vMjAyNiE=' | base64 -d
maria:Verano2026!

Esa cadena es justo lo que envía la autenticación HTTP Basic. Levantamos php -S 127.0.0.1:18157 con un script de tres líneas que imprime el header que recibe y lo llamamos con curl:

$ curl -sv -u 'maria:Verano2026!' http://127.0.0.1:18157/ 2>&1 | grep -E '^> Auth|^decoded'
> Authorization: Basic bWFyaWE6VmVyYW5vMjAyNiE=
decoded: maria:Verano2026!

O sea, Basic sobre HTTP plano manda la contraseña en forma legible. Solo TLS la protege (ver HTTPS, TLS y certificados (en inglés)). La regla: si no hay clave, no hay secreto.

Tu primer hash en 5 minutos: SHA-256

Los hashes trabajan con bytes, no con "texto". Con caracteres como la ñ, los bytes dependen de la codificación, así que primero pasamos a UTF-8. (Si la diferencia entre bytes y caracteres es nueva para ti, lee antes codificación de texto, bytes, UTF-8 y code points.)

import hashlib
for s in ["contraseña", "contraseña!"]:
    b = s.encode("utf-8")
    print(f"{s!r:14} {len(b):2d} bytes  {hashlib.sha256(b).hexdigest()}")
'contraseña'   11 bytes  edf9cf90718610ee7de53c0dcc250739239044de9ba115bb0ca6026c3e4958a5
'contraseña!'  12 bytes  5fcacb39d7c1f56e6f1179c7a5a8a52a0c32b750797f085c911ef4da8cd91cbc

Diez letras, pero 11 bytes: la ñ ocupa dos. SHA-256 es un estándar, así que otras herramientas dan el mismo valor:

$ printf 'contraseña' | shasum -a 256
edf9cf90718610ee7de53c0dcc250739239044de9ba115bb0ca6026c3e4958a5  -
$ printf 'Hello, OWASP' | openssl dgst -sha256
SHA2-256(stdin)= 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
$ php -r 'echo hash("sha256", "Hello, OWASP"), "\n";'
7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19

Ahora la primera trampa. Usa echo en lugar de printf y el hash cambia por completo:

$ echo 'contraseña' | shasum -a 256
f23747d24c71bbd7ac6750ac5cbb054d945bd32412b81b3034a99313ed711df6  -

echo agrega un salto de línea, así que hasheaste 12 bytes, no 11. Y hay otra trampa con la ñ: la misma palabra visible contraseña ocupa 11 bytes en forma NFC (ñ como un solo carácter) y 12 bytes en forma NFD (n + tilde combinable), y da dos hashes sin relación (edf9cf90… y 897184f7…). Si dos sistemas "calculan el mismo hash" y no coinciden, revisa primero los bytes.

El efecto avalancha: cambia un bit

Un buen hash tiene efecto avalancha: cambiar un bit de la entrada cambia cerca de la mitad de los bits de salida, sin patrón visible. Cambiamos H (0x48) por I (0x49), que es exactamente un bit:

b'Hello, OWASP' 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
b'Iello, OWASP' ab4d7b3d2213c9bc8a3286cb80924bcfc49d4c3a6eff5441f3a742727fae1c66
input bits changed : 1
output bits changed: 124 of 256

Un solo ejemplo podría ser suerte, así que lo repetimos 10,000 veces con entradas aleatorias de 32 bytes y una semilla fija:

import hashlib, random, statistics
rng = random.Random(2026)
bits = lambda b: int.from_bytes(b, "big")
counts = []
for _ in range(10_000):
    m = bytearray(rng.getrandbits(8) for _ in range(32))
    a = hashlib.sha256(m).digest()
    i = rng.randrange(256)
    m[i // 8] ^= 1 << (i % 8)          # invierte un bit al azar
    counts.append(bin(bits(a) ^ bits(hashlib.sha256(m).digest())).count("1"))
print(f"N={len(counts)} mean={statistics.mean(counts):.2f} "
      f"stdev={statistics.pstdev(counts):.2f} min={min(counts)} max={max(counts)}")
N=10000 mean=127.96 stdev=8.00 min=92 max=160
Histograma de bits de salida cambiados. De 96 a 103 bits: 13 entradas. De 104 a 111: 184. De 112 a 119: 1,244. De 120 a 127: 3,253. De 128 a 135: 3,588. De 136 a 143: 1,467. De 144 a 151: 234. De 152 a 159: 14. 160 o más: 1. La distribución se centra en 128, la mitad de los 256 bits.
Efecto avalancha medido: 10,000 entradas aleatorias de 32 bytes (semilla 2026 de random de Python), invirtiendo un bit de entrada cada vez. Las barras cuentan cuántos de los 256 bits de salida de SHA-256 cambiaron. Media 127.96, desviación estándar 8.00, mínimo 92, máximo 160.

Es la forma que darían 256 lanzamientos de una moneda justa: media 128, desviación estándar 8. Por eso no puedes "acercar" un hash a un valor objetivo, ni saber, comparando dos hashes, si las entradas se parecían.

Hash, MAC y cifrado, lado a lado

Un hash simple no tiene secreto. Si un servidor manda user_id=42&role=member y su SHA-256 al cliente, el cliente puede cambiar el rol y calcular un SHA-256 nuevo. Un MAC lo evita, porque la etiqueta necesita la clave del servidor:

import hmac, hashlib
key = b"server-side-secret-key-32-bytes!"
msg = b"user_id=42&role=member"
tag = hmac.new(key, msg, hashlib.sha256).hexdigest()
forged = b"user_id=42&role=admin"
print("tag     :", tag)
print("forged message accepted?",
      hmac.compare_digest(tag, hmac.new(key, forged, hashlib.sha256).hexdigest()))
tag     : 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6
forged message accepted? False

OpenSSL da la misma etiqueta, y así compruebas dos implementaciones entre sí:

$ printf 'user_id=42&role=member' | openssl dgst -sha256 -hmac 'server-side-secret-key-32-bytes!'
SHA2-256(stdin)= 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6

Aun así, un MAC no oculta nada: role=member se lee tal cual. Para ocultar necesitas cifrado, que se revierte con la clave y falla sin ella:

$ printf 'card ending 4242' > plain.txt
$ openssl enc -aes-256-cbc -pbkdf2 -salt -in plain.txt -out c.bin -pass pass:correct-horse
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:correct-horse
card ending 4242
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:wrong
bad decrypt
80E182F501000000:error:1C800064:Provider routines:ossl_cipher_unpadblock:bad decrypt:...
$ echo $?
1

Los primeros 8 bytes de c.bin son literalmente Salted__, seguidos de la sal aleatoria. Si omites -pbkdf2, OpenSSL 4.1 todavía imprime *** WARNING : deprecated key derivation used. Esa trampa, y por qué en realidad queremos AES-GCM en lugar de CBC, son el tema de la clase 5.

Errores comunes: qué pasa y cómo arreglarlo

1. "Codificamos la API key en Base64 dentro de la app, así que está oculta." Qué pasa: quien desempaque la app corre base64 -d y tiene la clave en segundos. Arreglo: un secreto que viaja dentro de una app cliente no es secreto. Déjalo en el servidor y dale a la app un token de corta duración (clase 3).

2. sha256(secreto + mensaje) como firma casera. Qué pasa: por cómo está construido SHA-256, con solo sha256(secreto + mensaje) un atacante puede calcular un hash válido para secreto + mensaje + relleno + extra. Es el ataque de extensión de longitud; aquí lo describimos sin ejecutarlo. Arreglo: usa hmac.new(key, msg, sha256) en Python o hash_hmac() en PHP, diseñados contra eso.

3. Comparar etiquetas con ==. Qué pasa: en PHP, "0e462097431906509019562988736854" == "0e830400451993494058024219903391" es true, porque ambos parecen el número 0 (lo ejecutamos en la clase 2). En cualquier lenguaje, == se detiene en el primer byte distinto y eso filtra tiempo. Arreglo: hmac.compare_digest() en Python o hash_equals() en PHP (clase 6).

4. "Desencriptar" un hash. Qué pasa: los sitios que dicen "desencriptar MD5" solo buscan el hash en una tabla enorme de hashes de textos conocidos. Arreglo: entender que el hash de una entrada adivinable es adivinable. Por eso las contraseñas necesitan sal y hashes lentos.

5. MD5 para "¿este archivo es el que publicamos?" Qué pasa: MD5 y SHA-1 tienen ataques de colisión prácticos: alguien puede construir dos archivos distintos con el mismo hash. Eso rompe firmas y verificaciones de descarga cuando el atacante elige el archivo. Arreglo: SHA-256 o BLAKE2 para integridad, y un MAC o una firma digital si el atacante también puede cambiar el hash publicado.

Medido: por qué MD5, SHA-1 y SHA-256 no sirven para contraseñas

Se suele decir "MD5 está roto" para explicar por qué es malo para contraseñas. Pero las colisiones no son la razón real. La razón real es la velocidad. Un hash de contraseñas tiene que hacer caro cada intento, y estas funciones se diseñaron para ser baratas. Medimos un núcleo de CPU con el benchmark de OpenSSL (entradas de 16 bytes, 2 segundos cada uno, 3 ejecuciones):

$ openssl speed -seconds 2 -bytes 16 -evp sha256
Doing sha256 ops for 2s on 16 size blocks: 15614250 sha256 ops in 1.98s
Gráfico de barras de hashes por segundo en un núcleo: MD5 5.75 millones, SHA-1 7.77 millones, SHA-256 7.89 millones.
Cuántas entradas de 16 bytes hashea un núcleo de CPU por segundo (millones), con 'openssl speed -evp -bytes 16 -seconds 2' de OpenSSL 4.1, mediana de 3 ejecuciones en una Mac Apple Silicon (M1 Pro). Aquí SHA-256 no es más lento que MD5: el chip tiene instrucciones SHA. Los tres son demasiado rápidos para contraseñas.

SHA-256 corrió a unos 7.89 millones de hashes por segundo en un núcleo, y MD5 a 5.75 millones. En este chip SHA-256 ni siquiera es más lento que MD5, porque Apple Silicon tiene instrucciones para SHA-256. Pasar de MD5 a SHA-256 no agrega nada para contraseñas.

Pasémoslo a tiempo. Todas las contraseñas de 8 letras minúsculas son 26⁸ ≈ 2.09 × 10¹¹ candidatas: 2.09 × 10¹¹ / 7.89 × 10⁶ ≈ 26,500 segundos, unas 7.4 horas en un núcleo de laptop. Una lista de un millón de contraseñas comunes tarda unos 0.13 segundos. Los atacantes usan muchos núcleos y GPUs, que son mucho más rápidos (aquí no lo medimos).

Desde Python los números bajan (3.25 millones de SHA-256 por segundo con entradas de 8 bytes, N = 200,000, mediana de 5 ejecuciones), porque el bucle del intérprete cuesta más que el hash. Cuando estimes la velocidad de un atacante, usa la implementación más rápida, no la tuya.

Qué herramienta para qué trabajo

Quieres… Usa No uses
Meter bytes en JSON, una URL o un correo Base64 / Base64url creer que oculta algo
Verificar una descarga o detectar corrupción accidental SHA-256, BLAKE2 MD5, SHA-1
Probar que una cookie o un webhook vino de ti HMAC-SHA256 con clave secreta sha256(secreto + msg)
Mantener datos en secreto y recuperarlos después AES-GCM o libsodium (clase 5) Base64, XOR, modo ECB
Guardar contraseñas bcrypt, Argon2id, scrypt, PBKDF2 (clase 2) MD5, SHA-1, SHA-256, cifrado
Generar tokens y claves secrets / random_bytes (clase 3) random, la hora, un hash del ID

Fíjate en que "cifrar las contraseñas" también está en la columna de la derecha. El cifrado se revierte con la clave; si la clave se filtra junto con la base de datos, es como guardarlas en texto plano.

Límites honestos y notas de versión

  • Ejecutado con Python 3.9.6 (en esta Mac su hashlib está enlazado a LibreSSL 2.8.3), PHP 8.5.6 y OpenSSL 4.1.0. Los valores de hash son estándar y coinciden en cualquier plataforma; las velocidades son de este núcleo M1 Pro.
  • openssl speed mide código C con instrucciones SHA por hardware. Es una referencia aproximada de un atacante con un núcleo de CPU, no un número de GPU.
  • Las estadísticas de avalancha salen de una semilla (2026) y N = 10,000. Otra semilla da conteos algo distintos con la misma forma.
  • La extensión de longitud y las colisiones de MD5 se describen, no se ejecutan. SHA-256 resiste colisiones pero sí está expuesto a la extensión de longitud; HMAC y SHA-3 no.
  • La numeración del OWASP Top 10 cambia entre ediciones; solo citamos el nombre de la categoría de 2021 (Cryptographic Failures).

Ejercicios

1. Sin ejecutar nada: ¿qué imprime echo -n 'contraseña' | shasum -a 256, y por qué difiere de echo 'contraseña' | shasum -a 256?

2. Un compañero guarda base64(user_id + ":" + vencimiento) en una cookie para recordar sesiones. Nombra el problema y la herramienta de la tabla que lo arregla.

3. A 7.89 millones de hashes por segundo, ¿cuánto tarda un núcleo en probar todos los PIN de 6 dígitos (000000 a 999999) guardados con SHA-256 sin sal?

Respuestas

1. Imprime edf9cf90…58a5, lo mismo que printf, porque -n suprime el salto de línea. El echo normal hashea 12 bytes y da f23747d2…1df6. (El echo integrado de zsh acepta -n; en algunas versiones de sh no, otra razón para preferir printf.)

2. Base64 es reversible y no tiene clave, así que cualquier usuario puede decodificarlo, cambiar el ID y volver a codificarlo. Agrega una etiqueta HMAC-SHA256 con una clave del servidor y verifícala con una comparación de tiempo constante (o usa un ID de sesión aleatorio guardado en el servidor).

3. 10⁶ / 7.89 × 10⁶ ≈ 0.13 segundos. Un PIN de 6 dígitos no tiene ninguna oportunidad contra un atacante offline, sea cual sea el hash. Por eso la próxima clase también habla de límites de intentos y de la longitud de la contraseña.

Lo que sigue

Preguntas frecuentes

¿Base64 es una forma de cifrado?

No. Base64 solo reescribe bytes con 64 caracteres imprimibles para que pasen por canales de texto. No hay clave: base64 -d o una línea de Python convierten bWFyaWE6VmVyYW5vMjAyNiE= en maria:Verano2026! al instante. El header Authorization de HTTP Basic es Base64, por eso solo debe viajar por HTTPS.

¿Qué diferencia hay entre un hash, un MAC y el cifrado?

Un hash como SHA-256 es una huella de un solo sentido, sin clave, que cualquiera puede calcular. Un MAC como HMAC-SHA256 mezcla una clave secreta, así que solo quien tiene la clave produce una etiqueta válida y los mensajes falsificados se rechazan. El cifrado va en dos sentidos: con la clave recuperas el original. Solo el cifrado oculta datos y solo un MAC prueba quién los escribió.

¿Por qué no debo guardar contraseñas con MD5, SHA-1 o SHA-256?

Porque son rápidos. Un núcleo de un Apple M1 Pro calculó unos 7.89 millones de SHA-256 por segundo y 5.75 millones de MD5 por segundo en nuestra prueba con openssl speed, así que todas las contraseñas de 8 letras minúsculas caen en unas 7.4 horas. Las contraseñas necesitan un hash lento y con sal como bcrypt o Argon2id.

  • #seguridad
  • #principiante
  • #hash
  • #cifrado
  • #base64
  • #sha-256
  • #hmac

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑