De vez en cuando, escuchamos casos de servidores infectados, de robos de base de datos o de aplicaciones comprometidas. Y, aunque uno no este siempre al tanto, esto suele ser pan de cada día. Por ello, es importante que nos aseguremos de que los datos de los usuarios, sobre todo los más importantes, como las contraseñas, no puedan ser recuperadas por los atacantes de nuestras aplicaciones.
En este artículo vamos a ver los fundamentos detrás de Hash y lo que se necesita para proteger las contraseñas en nuestras aplicaciones web...
1. Responsabilidad
La criptología es un tema muy complicado, por lo que aquí vamos a mantener las cosas simples, tratando de presentar un método razonablemente seguro de cómo almacenar las contraseñas en una aplicación web.
2. ¿Qué es lo que hace el “Hashing”?
El Hashing convierte un trozo de datos (ya sea pequeño o grande), en una relativamente pequeña pieza de datos como una cadena (string) o un entero.
Esto se logra usando una función hash unidireccional. “Sólo de ida” significa que es muy difícil (o prácticamente imposible) de revertirla.
Un ejemplo conocido de una función hash es md5(), la cual es muy popular en diferentes lenguajes y sistemas.
<?php
$data = "Hola Mundo";
$hash = md5($data);
echo $hash; // b10a8db164e0754105b7a99be72e3fe5
?>
Con md5(), el resultado siempre será un string de 32 caracteres de longitud. Pero, contendrá únicamente caracteres hexadecimales; técnicamente también puede ser representado como un entero de 128-bit (16 byte). Es posible que enviamos cadenas de caracteres o datos muy grandes, pero con md5() siempre obtendremos un hash de la misma longitud. Este hecho por si sólo puede darnos una idea de porque se le considera una función de “Sólo de ida”.
3. Usando una función hash para almacenar las contraseñas
El proceso habitual durante el registro de un usuario es:
- El usuario llena el formulario de registro, incluyendo el campo de contraseña
- Los scripts almacenan toda la información en una base de datos
- Sin embargo, el password es convertido con una función hash, antes de ser almacenado.
- La versión original del password no ha sido almacenada en ningún lado, por lo que es técnicamente borrada
Y el proceso de login es:
- El usuario ingresa su nombre de usuario (o e-mail) y contraseña
- El script convierte la contraseña utilizando la misma función hash
- El script busca el registro del usuario en la base de datos y lee la “contraseña hasheada” almacenada
- Ambos valores (contraseña introducida y hasheada vs. contraseña hasheada almacenada) son comparados, y si coinciden el acceso es concedido.
Hay que tener en cuenta que la contraseña original nunca ha sido almacenada en la base de datos. Si esta fuera robada, los datos de inicio de sesión del usuario no estarían comprometidos, ¿no? Bueno, la respuesta es “depende”. Veamos algunos problemas potenciales.
4. Problema #1 Colisión Hash
Una “colisión” hash se produce cuando dos entradas de datos diferentes generan el mismo hash resultante. La probabilidad de que esto ocurra depende de la función que uno usa.
¿Cómo puede ser explotado?
Como ejemplo, podemos ver algunos scripts antiguos que utilizan la función crc32() para cifrar las contraseñas. Esta función genera un número de 32 bits como resultado. Esto significa que hay sólo 2 ^ 32 (es decir, 4294967296) posibles resultados.
Vamos a hashear una contraseña:
<?php
echo crc32('supersecretpassword');
// outputs: 323322056
?>
Ahora, vamos a asumir el papel de una persona que ha robado una base de datos y tiene el valor hash. Puede que no seamos capaces de convertir 323322056 en 'supersecretpassword'; sin embargo, podemos encontrar otra contraseña que se convierta en el mismo valor hash, con un simple script:
<?php
set_time_limit(0);
$i = 0;
while (true) {
if (crc32(base64_encode($i)) == 323322056) {
echo base64_encode($i);
exit;
}
$i++;
}
?>
Esto puede ejecutarse durante un tiempo, aunque, finalmente, devolverá una cadena. Podemos usar esta cadena en lugar de 'supersecretpassword' y, con ello, iniciar sesión correctamente en la cuenta de esa persona.
Por ejemplo, después de ejecutar este script por un momento, obtuvimos esta cadena ‘MTIxMjY5MTAwNg==
’. Ahora, hagamos la prueba:
<?php
echo crc32('supersecretpassword');
// outputs: 323322056
echo crc32('MTIxMjY5MTAwNg==');
// outputs: 323322056
?>
¿Cómo podemos prevenir esto?
Hoy en día, un potente ordenador casero puede ejecutar una función hash casi mil millones de veces por segundo. Por lo que necesitamos una función hash que tenga un rango muy grande.
Por ejemplo, md5() puede ser adecuado, ya que genera hashes de 128 bits. Esto se traduce en 340,282,366,920,938,463,463,374,607,431,768,211,456 posibles resultados. Lo que hace prácticamente imposible realizar tantas iteraciones para encontrar colisiones. Sin embargo, algunas personas todavía encuentran maneras de lograrlo.
Sha1
Sha1() es una mejor alternativa, ya que genera un valor hash de 160 bits.
5. Problema #2: Las tablas arcoíris
Incluso si se solucionara el problema de colisión, todavía no estaríamos completamente seguros.
Una tabla arcoíris se construye mediante el cálculo de los valores hash de las palabras utilizadas y de sus combinaciones.
Estas tablas pueden tener millones o incluso miles de millones de filas.
Por ejemplo, podemos usar un diccionario y generar valores hash para cada palabra. También podemos combinar palabras y generar hashes de los mismos. Eso no es todo, incluso se pueden añadir dígitos antes/después/o entre las palabras y almacenarlas en la tabla, también.
Teniendo en cuenta lo cómodo del almacenamiento de hoy en día, las tablas arcoíris gigantes pueden producirse y ser utilizadas.
¿Cómo puede ser explotada?
Imaginemos que una gran base de datos es robada, con cerca de 10 millones de hashes de contraseñas. Es bastante fácil buscar en la tabla arcoíris para encontrar cada una de ellas. No todas serán encontradas, sin duda, pero, sin embargo... si algunos de ellas!
¿Cómo podemos evitar esto?
Podemos tratar de añadir un “salt”. Por ejemplo:
<?php
$password = "easypassword";
// esto puede encontrarse en una tabla arcoíris
// porque la contraseña contiene 2 palabras comunes
echo sha1($password); // 6c94d3b42518febd4ad747801d50a8972022f956
// usa un montón de caracteres al azar. Puede ser más largo que este
$salt = "f#@V)Hu^%Hgfds";
// Esto no será encontrado en ninguna tabla arcoiris pre-construida
echo sha1($salt . $password); // cd56a16759623378628c0d9336af69b74d9d71a5
?>
Lo que hacemos es básicamente concatenar la cadena “salt” ( o “sal”) con la cadena hash de las contraseñas, antes de ellas. La cadena resultante, obviamente, no se encontrará en ninguna tabla arcoíris. Sin embargo, no estamos seguros aún!
6. Problema #3: Las tablas arcoíris (otra vez)
Recuerden que una tabla arcoíris puede crearse desde cero, después de que la base de datos haya sido robada.
¿Cómo puede ser explotada?
Incluso si se utiliza un “salt”, este pudo haber sido robado junto con la base de datos. Todo lo que se tiene que hacer entonces, es generar una nueva tabla arcoíris, pero esta vez se concatenará la “sal” a cada palabra que ellos inserten en la tabla.
Por ejemplo, en una tabla arcoíris genérica, “easypassword” puede existir. Pero, en esta nueva tabla Rainbow, ellos tienen “f#@V)Hu^%Hgfdseasypassword
” también. Cuando se ejecute la totalidad de los 10 millones de hashes con sal robados de la tabla, tendrán nuevamente la capacidad de encontrar algunos accesos.
¿Cómo podemos prevenir esto?
Podemos usar un Salt único para cada usuario.
Un candidato para este tipo de salt es el valor user id de la base de datos:
<?php
$hash = sha1($user_id . $password);
?>
Esto es asumiendo que el id del usuario nunca cambia, lo cual es generalmente el caso.
También podemos generar un string aleatorio para cada usuario y usarlo como salt único. Pero, necesitaremos asegurarnos que serán almacenados en cualquier parte de los registros del usuario.
<?php
// genera una cadena de 22 caracteres de tamaño
function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
$unique_salt = unique_salt();
$hash = sha1($unique_salt . $password);
// y almacena la variable $unique_salt con el registro del usuario
// ...
?>
Este método nos protege contra las tablas arcoíris, porque la contraseña de todos y cada uno de los usuarios se ha “salado” con un valor diferente. El atacante tendría que generar 10 millones de tablas arcoíris, lo que sería totalmente impracticable.
7. Problema #4: La velocidad del Hash
La mayoría de las funciones hash se han diseñado pensando en la velocidad, porque a menudo se utilizan para calcular valores de control de grandes conjuntos de datos y archivos, para comprobar la integridad de sus datos.
¿Cómo se puede explotar esto?
Como mencionamos anteriormente, una PC moderna con una GPU potente puede ser programada para calcular aproximadamente mil millones de hashes por segundo. De esta manera, pueden utilizar un ataque de fuerza bruta para probar todas las contraseñas simples posibles.
Uno podría suponer que exigir un mínimo de 8 caracteres para la contraseña podría mantenernos a salvo de un ataque de fuerza bruta, pero vamos a esto es, de hecho, así:
- Si la contraseña puede contener letras minúsculas, letras mayúsculas y números, son 62 (26+26+10) caracteres posibles.
- Una cadena de 8 caracteres de largo tiene 62 ^ 8 versiones posibles. Esto es un poco más de 218 mil millones de alternativas.
- A un ritmo de mil millones de hashes por segundo, puede quedar resuelto en 60 horas.
Y por contraseñas de 6 caracteres de longitud, que también son bastantes comunes, tomaría menos de 1 minuto.
No duden en exigir de 9 o 10 caracteres de largo; sin embargo, podríamos ser algo fastidiosos para algunos usuarios.
¿Cómo lo evitamos?
Usar una función hash más lenta.
Imagine que se utiliza una función hash que sólo se pueda ejecutar 1 millón de veces por segundo en el mismo hardware, en lugar de mil millones de veces por segundo. Entonces, esto le tomaría al atacante 1000 veces más tiempo para que pueda aplicar la fuerza bruta a un hash. 60 horas se convertirían en casi 7 años.
Una manera de aplicarlo nosotros mismos sería:
<?php
function myhash($password, $unique_salt) {
$salt = "f#@V)Hu^%Hgfds";
$hash = sha1($unique_salt . $password);
// lo hace tomar 1000 veces más
for ($i = 0; $i < 1000; $i++) {
$hash = sha1($hash);
}
return $hash;
}
?>
O podríamos utilizar un algoritmo que soporta un “parámetro de costo”, como un BLOWFISH. En PHP, podríamos hacer esto usando la función crypt():
<?php
function myhash($password, $unique_salt) {
// la sal de blowfish debe ser de 22 caracteres de largo
return crypt($password, '$2a$10$'.$unique_salt);
}
?>
El segundo parámetro dado a la función crypt() contiene algunos valores separados por el signo de dólar ($).
El primer valor es '$2a', el cual indica que vamos a utilizar el algoritmo Blowfish.
El segundo valor, '$10' en este caso, es el “parámetro de costo”. Este es el logaritmo de base 2 del número de iteraciones que se ejecutarán (10 => 2^10 = 1024 iteraciones). Este número puede oscilar entre 04 y 31.
Vamos a probar un ejemplo:
<?php
function myhash($password, $unique_salt) {
return crypt($password, '$2a$10$'.$unique_salt);
}
function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
$password = "verysecret";
echo myhash($password, unique_salt());
// resultado: $2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC
?>
El hash resultante contiene el algoritmo ($2a), el parámetro de costo ($10), y la sal de 22 caracteres que se utilizó. El resto es el hash calculado. Vamos a realizar una prueba:
<?php
// assume this was pulled from the database
$hash = '$2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC';
// assume this is the password the user entered to log back in
$password = "verysecret";
if (check_password($hash, $password)) {
echo "¡Acceso concedido!";
} else {
echo "¡Acceso denegado!";
}
function check_password($hash, $password) {
// los primeros 29 caracteres incluyen el algoritmo, el costo y la sal
// vamos a llamar a $full_salt
$full_salt = substr($hash, 0, 29);
//ejecutamos la función hash sobre $password
$new_hash = crypt($password, $full_salt);
// devolvemos true o false
return ($hash == $new_hash);
}
?>
Al ejecutar esto, veremos “¡Acceso concedido!”.
8. Poniendo todo junto
Con todo lo anterior en mente, vamos a escribir una clase útil basada en lo que hemos aprendido:
<?php
class PassHash {
// blowfish
private static $algo = '$2a';
// parámetro de costo
private static $cost = '$10';
// principalmente para uso interno
public static function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
// este será utilizado para generar un hash
public static function hash($password) {
return crypt($password,
self::$algo .
self::$cost .
'$' . self::unique_salt());
}
// este se utilizará para comparar una contraseña con un hash
public static function check_password($hash, $password) {
$full_salt = substr($hash, 0, 29);
$new_hash = crypt($password, $full_salt);
return ($hash == $new_hash);
}
}
?>
Aquí vemos su uso durante el registro del usuario:
<?php
// incluir la clase
require ("PassHash.php");
// leer todas las variables $_POST del formulario
// ...
// Proceder a la validación del formulario
// ...
// hash de la contraseña
$pass_hash = PassHash::hash($_POST['password']);
// almacenar la información del usuario en la DB, excluyendo $_POST['password']
// almacenar $pass_hash en su lugar
// ...
?>
Y aquí vemos su uso durante el proceso de inicio de sesión del usuario:
<?php
//incluir la clase
require ("PassHash.php");
// leer todas las variables $_POST del formulario
// ...
// buscar el registro de usuario basado en $_POST['username'] o similar
// ...
// comprobar la contraseña del usuario que ha intentado iniciar sesión con
if (PassHash::check_password($user['pass_hash'], $_POST['password']) {
// acceso concedido
// ...
} else {
// acceso denegado
// ...
}
?>
9. Una nota sobre la disponibilidad de Blowfish
El algoritmo Blowfish no puede ser aplicado en todos los sistemas, a pesar de que es muy popular por ahora. Uno puede revisar su sistema con el siguiente código:
<?php
if (CRYPT_BLOWFISH == 1) {
echo "Yes";
} else {
echo "No";
}
?>
Sin embargo, a partir de PHP 5.3, no hay que preocuparse; PHP se maneja con esta implementación hecha para ello.
Conclusión
Este método para hashear contraseñas debería de ser lo suficientemente sólido para la mayoría de aplicaciones web. Dicho esto, no olvidemos que: Podemos solicitar a nuestros usuarios que usen contraseñas más complejas, considerando un tamaño mínimo de largo y combinando letras, números y caracteres especiales.
Una última pregunta: ¿Podrías recomendar alguna mejora a esta implementación?
Fuente: NetTuts
Yo he usado Rijandel (nunca calculé el tiempo que tarda en crackearse)
Y sobre los config files de los cable modems, vienen firmados en HMAC-MD5, ese si está en chino hacerle la fuerza bruta.
Gracias por la info.
Muy bueno el artículo, mientras estaba pensando en como proteger la contraseña en un sistema de usuarios, y sin estos conocimientos que planteas en la entrada, a mi se me ocurrio una mejora junto al hash md5:
Resulta que varias opciones pasan por la id del usuario (como mencionaste en la entrada), la contraseña, y una encriptacion personalizaba, basada en una funcion implementada por mi en la que realizaba un cambio de orden de la palabra según unas pautas, algunos fragmentos se pasaban a hexadecimal y todo esto pasaba por el md5... junto a esto y lo que has publicado se puede crear un sistema de encriptado muy potente para las contraseñas :)
Saludos! y gran artículo.
Hola, que tal.
Muy bueno el POST.
Ahora me queda muy claro algunas cosas.
Sobre todo el BLOWFISH de CRYPT.
Sólo hay una cosa que no entiendo.
Y es que no sé de dónde sacar: $user['pass_hash']
Que está en el proceso de inicio de sesión del usuario.
Es decir, de donde saco $user['pass_hash']
Ojalá me puedan ayudar.
Muchas gracias.
mucho mas entendible que en la documentación oficial de php
gracias
Una explicación sencilla y extraordinaria.¡Felicidades y gracias!
Excelente!!1 Muy buen artículo, seguro me servirá de mucho para prontos proyectos!
Gracias!
Excelente... como siempre!!!