1. Code
  2. PHP

Comprender las funciones hash y mantener seguras las contraseñas

Scroll to top

Spanish (Español) translation by Valeria Angulo (you can also view the original English article)

De vez en cuando, los servidores y las bases de datos son robados o comprometidos. Con esto en mente, es importante asegurar que algunos datos cruciales de los usuarios, como las contraseñas, no puedan ser recuperados. Hoy, vamos a aprender los fundamentos detrás del hashing y lo que se necesita para proteger las contraseñas en sus aplicaciones web.

Tutorial reeditado

Cada pocas semanas, revisamos algunos de los posts favoritos de nuestros lectores a lo largo de la historia del sitio web. Este tutorial fue publicado por primera vez en enero de 2011.


1. Descargo de responsabilidad

La criptología es un tema suficientemente complicado, y de ninguna manera soy un experto. Hay una investigación constante en esta área, en muchas universidades y agencias de seguridad.

En este artículo, intentaré mantener las cosas tan simples  como sea posible, mientras te presento un método razonablemente seguro para almacenar contraseñas en una aplicación web.


2. ¿Qué hace el "hashing"?

El hashing convierte una pieza de dato (ya sea pequeño o grande) en un dato relativamente corto, como una cadena o un número entero.

Esto se logra mediante el uso de una función hash de una sola vía. "Unidireccional" significa que es muy difícil (o prácticamente imposible) revertirlo.

Un ejemplo común de función hash es md5(), que es bastante popular en muchos lenguajes y sistemas diferentes.

1
$data = "Hello World";
2
$hash = md5($data);
3
echo $hash; // b10a8db164e0754105b7a99be72e3fe5

Con md5(), el resultado siempre será una cadena de 32 caracteres. Pero, solo contiene caracteres hexadecimales; técnicamente también puede representarse como un entero de 128 bits (16 bytes). Puedes hacer md5() a cadenas y datos mucho más largos, y aun así terminará con un hash de esta longitud. Este hecho por sí solo puede darte una pista de por qué se considera una función "unidireccional".


3. Uso de una función hash para almacenar contraseñas

El proceso habitual durante el registro de un usuario:

  • El usuario rellena el formulario de registro, incluyendo el campo de la contraseña.
  • El script web almacena toda la información en una base de datos.
  • Sin embargo, la contraseña se ejecuta a través de una función hash, antes de ser almacenada.
  • La versión original de la contraseña no se almacena en ningún sitio, por lo que técnicamente se descarta.

Y el proceso de inicio de sesión:

  • El usuario introduce el nombre de usuario (o el correo electrónico) y la contraseña.
  • El script ejecuta la contraseña a través de la misma función hashing.
  • El script encuentra el registro del usuario en la base de datos y lee la contraseña con hash almacenada.
  • Ambos valores se comparan, y el acceso se concede si coinciden.

Una vez que decidimos un método decente para el hashing de la contraseña, vamos a implementar este proceso más adelante en este artículo.

Ten en cuenta que la contraseña original no se almacena en ningún sitio. Si la base de datos es robada, los registros de los usuarios no pueden ser comprometidos, ¿verdad? Bueno, la respuesta es "depende". Veamos algunos problemas potenciales.


4. Problema #1: Colisión de Hash

Una "colisión" de 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 se utilicen.

¿Cómo se puede explotar esto?

Como un ejemplo, he visto algunos scripts antiguos que usaban crc32() para hacer el hash de las contraseñas. Esta función genera un entero de 32 bits como resultado. Esto significa que sólo hay 2^32 (es decir, 4.294.967.296) resultados posibles.

Hagamos un hash de una contraseña:

1
echo crc32('supersecretpassword');
2
// outputs: 323322056

Ahora, asumamos 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 averiguar otra contraseña que se convierta en el mismo valor hash, con un simple script:

1
set_time_limit(0);
2
$i = 0;
3
while (true) {
4
5
	if (crc32(base64_encode($i)) == 323322056) {
6
		echo base64_encode($i);
7
		exit;
8
	}
9
10
	$i++;
11
}

Esto puede ejecutarse durante un tiempo, aunque, finalmente, debería devolver una cadena. Podemos usar esta cadena devuelta -- en lugar de 'supersecretpassword' -- y nos permitirá entrar con éxito en la cuenta de esa persona.

Por ejemplo, después de ejecutar este mismo script durante unos instantes en mi computadora, obtuve 'MTIxMjY5MTAwNg=='. Vamos a probarlo:

1
echo crc32('supersecretpassword');
2
// outputs: 323322056

3
4
echo crc32('MTIxMjY5MTAwNg==');
5
// outputs: 323322056

¿Cómo se puede evitar esto?

Hoy en día, un potente PC doméstico puede ejecutar una función hash casi mil millones de veces por segundo. Así que necesitamos una función hash que tenga un rango muy grande.

Por ejemplo, md5() podría ser adecuada, ya que genera hashes de 128 bits. Esto se traduce en 340.282.366.920.938.463.463.374.607.431.768.211.456 resultados posibles. Es imposible realizar tantas iteraciones para encontrar colisiones. Sin embargo, algunas personas han encontrado formas de hacerlo (ver aquí).

Sha1

Sha1() es una alternativa mejor, y genera un valor hash aún más largo de 160 bits.


5. Problema #2: Tablas Rainbow

Incluso si arreglamos el problema de la colisión, todavía no estamos seguros.

Una tabla rainbow (arco iris) se construye calculando los valores hash de las palabras más utilizadas y sus combinaciones.

Estas tablas pueden tener hasta millones o incluso miles de millones de filas.

Por ejemplo, puedes recorrer un diccionario y generar valores hash para cada palabra. También puedes empezar a combinar palabras y generar hashes para ellas. Eso no es todo; incluso puedes empezar a añadir dígitos antes/después/entre las palabras, y almacenarlos también en la tabla.

Teniendo en cuenta lo barato que es el almacenamiento hoy en día, se pueden producir y utilizar gigantescas tablas Rainbow.

¿Cómo se puede aprovechar esto?

Imaginemos que te robas una gran base de datos, junto con 10 millones de hashes de contraseñas. Es bastante fácil buscar en la tabla rainbow cada una de ellas. No se encontrarán todas, ciertamente, pero, sin embargo...¡algunas sí!

¿Cómo se puede evitar esto?

Podemos intentar añadir una "salt". He aquí un ejemplo:

1
$password = "easypassword";
2
3
// this may be found in a rainbow table

4
// because the password contains 2 common words

5
echo sha1($password); // 6c94d3b42518febd4ad747801d50a8972022f956

6
7
// use bunch of random characters, and it can be longer than this

8
$salt = "f#@V)Hu^%Hgfds";
9
10
// this will NOT be found in any pre-built rainbow table

11
echo sha1($salt . $password); // cd56a16759623378628c0d9336af69b74d9d71a5

Lo que hacemos básicamente es concatenar la cadena "salt" con las contraseñas antes de hacer el hash. La cadena resultante obviamente no estará en ninguna tabla rainbow pre-construida. Pero, ¡todavía no estamos seguros!


6. Problema #3: Tablas Rainbow (de nuevo)

Recuerda que una tabla Rainbow puede ser creada desde cero, después de que la base de datos haya sido robada.

¿Cómo puedes aprovechar esto?

Aunque se haya utilizado una salt, esta puede haber sido robada junto con la base de datos. Todo lo que tienen que hacer es generar una nueva Tabla Rainbow desde cero, pero esta vez concatenan la salt a cada palabra que están poniendo en la tabla.

Por ejemplo, en una Tabla Rainbow genérica, puede existir "easypassword". Pero en esta nueva Tabla Rainbow, tienen "f#@V)Hu^%Hgfdseasypassword" también. Cuando se ejecutan todos los 10 millones de hashes robados con salt contra esta tabla, serán capaces de encontrar de nuevo algunas coincidencias.

¿Cómo se puede evitar esto?

Podemos utilizar una "salt única" en su lugar, que cambia para cada usuario.

Un candidato para este tipo de salt es el valor del id del usuario de la base de datos:

1
$hash = sha1($user_id . $password);

Esto es asumiendo que el número de identificación de un usuario nunca cambia, que es el caso típico.

También podemos generar una cadena aleatoria para cada usuario y usarla como salt única. Pero tendríamos que asegurarnos de almacenar eso en el registro del usuario en algún lugar.

1
// generates a 22 character long random string

2
function unique_salt() {
3
4
	return substr(sha1(mt_rand()),0,22);
5
}
6
7
$unique_salt = unique_salt();
8
9
$hash = sha1($unique_salt . $password);
10
11
// and save the $unique_salt with the user record

12
// ...

Este método nos protege contra las Tablas Rainbow, porque ahora cada contraseña ha sido salted (salada) con un valor diferente. El atacante tendría que generar 10 millones de Tablas Rainbow por separado, lo que sería completamente impráctico.


7. Problema #4: Velocidad del Hash

La mayoría de las funciones de hash han sido diseñadas teniendo en cuenta la velocidad, porque a menudo se utilizan para calcular valores de suma de comprobación para grandes conjuntos de datos y archivos, para comprobar la integridad de los datos.

¿Cómo puede aprovecharse esto?

Como he mencionado antes, un PC moderno con potentes GPU (sí, tarjetas de vídeo) puede programarse para calcular aproximadamente mil millones de hashes por segundo. De este modo, pueden utilizar un ataque de fuerza bruta para probar todas las contraseñas posibles.

Puedes pensar que exigir una contraseña de al menos 8 caracteres de longitud podría mantenerla a salvo de un ataque de fuerza bruta, pero vamos a determinar si ese es, efectivamente, el caso:

  • Si la contraseña puede contener minúsculas, mayúsculas y números, son 62 (26+26+10) caracteres posibles.
  • Una cadena de 8 caracteres tiene 62^8 versiones posibles. Es decir, algo más de 218 billones.
  • A un ritmo de 1.000 millones de hashes por segundo, eso puede resolverse en unas 60 horas.

Y para contraseñas de 6 caracteres de longitud, que también es bastante común, se tardaría menos de 1 minuto.

Si se puede exigir contraseñas de 9 o 10 caracteres, se podría empezar a molestar a algunos de los usuarios.

¿Cómo se puede evitar esto?

Utiliza una función hash más lenta.

Imagina que utilizas una función hash que solo puede ejecutarse 1 millón de veces por segundo en el mismo hardware, en lugar de 1.000 millones de veces por segundo. Entonces el atacante tardaría 1000 veces más en forzar un hash. ¡60 horas se convertirían en casi 7 años!

Una forma de hacerlo sería implementarlo tú mismo:

1
function myhash($password, $unique_salt) {
2
3
	$salt = "f#@V)Hu^%Hgfds";
4
	$hash = sha1($unique_salt . $password);
5
6
	// make it take 1000 times longer

7
	for ($i = 0; $i < 1000; $i++) {
8
		$hash = sha1($hash);
9
	}
10
11
	return $hash;
12
}

O puedes utilizar un algoritmo que admita un "parámetro de costo", como BLOWFISH. En PHP, esto puede hacerse utilizando la función crypt().

1
function myhash($password, $unique_salt) {
2
3
	// the salt for blowfish should be 22 characters long

4
5
	return crypt($password, '$2a$10$'.$unique_salt);
6
7
}

El segundo parámetro de la función crypt() contiene algunos valores separados por el signo de dólar ($).

El primer valor es '$2a', que indica que utilizaremos el algoritmo BLOWFISH.

El segundo valor, '$10' en este caso, es el "parámetro de costo". Es el logaritmo en base-2 de cuántas iteraciones se ejecutarán (10 => 2^10 = 1024 iteraciones.) Este número puede oscilar entre 04 y 31.

Vamos a ejecutar un ejemplo:

1
function myhash($password, $unique_salt) {
2
	return crypt($password, '$2a$10$'.$unique_salt);
3
4
}
5
function unique_salt() {
6
	return substr(sha1(mt_rand()),0,22);
7
}
8
9
10
$password = "verysecret";
11
12
echo myhash($password, unique_salt());
13
// result: $2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC

El hash resultante contiene el algoritmo ($2a), el parámetro de costo ($10) y la salt de 22 caracteres que se ha utilizado. El resto es el hash calculado. Hagamos una prueba:

1
// assume this was pulled from the database

2
$hash = '$2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC';
3
4
// assume this is the password the user entered to log back in

5
$password = "verysecret";
6
7
if (check_password($hash, $password)) {
8
	echo "Access Granted!";
9
} else {
10
	echo "Access Denied!";
11
}
12
13
14
function check_password($hash, $password) {
15
16
	// first 29 characters include algorithm, cost and salt

17
	// let's call it $full_salt

18
	$full_salt = substr($hash, 0, 29);
19
20
	// run the hash function on $password

21
	$new_hash = crypt($password, $full_salt);
22
23
	// returns true or false

24
	return ($hash == $new_hash);
25
}

Cuando ejecutamos esto, vemos "¡Acceso concedido!".


8. Cómo ponerlo en práctica

Con todo lo anterior en mente, vamos a escribir una clase de utilidad basada en lo que hemos aprendido hasta ahora:

1
class PassHash {
2
3
	// blowfish

4
	private static $algo = '$2a';
5
6
	// cost parameter

7
	private static $cost = '$10';
8
9
10
	// mainly for internal use

11
	public static function unique_salt() {
12
		return substr(sha1(mt_rand()),0,22);
13
	}
14
15
	// this will be used to generate a hash

16
	public static function hash($password) {
17
18
		return crypt($password,
19
					self::$algo .
20
					self::$cost .
21
					'$' . self::unique_salt());
22
23
	}
24
25
26
	// this will be used to compare a password against a hash

27
	public static function check_password($hash, $password) {
28
29
		$full_salt = substr($hash, 0, 29);
30
31
		$new_hash = crypt($password, $full_salt);
32
33
		return ($hash == $new_hash);
34
35
	}
36
37
}

Este es el uso durante el registro del usuario:

1
// include the class

2
require ("PassHash.php");
3
4
// read all form input from $_POST

5
// ...

6
7
// do your regular form validation stuff

8
// ...

9
10
// hash the password

11
$pass_hash = PassHash::hash($_POST['password']);
12
13
// store all user info in the DB, excluding $_POST['password']

14
// store $pass_hash instead

15
// ...

Y aquí está el uso durante un proceso de inicio de sesión de usuario:

1
// include the class

2
require ("PassHash.php");
3
4
// read all form input from $_POST

5
// ...

6
7
// fetch the user record based on $_POST['username']  or similar

8
// ...

9
10
// check the password the user tried to login with

11
if (PassHash::check_password($user['pass_hash'], $_POST['password']) {
12
	// grant access

13
	// ...

14
} else {
15
	// deny access

16
	// ...

17
}

9. Una nota sobre la disponibilidad de Blowfish

El algoritmo Blowfish puede no estar implementado en todos los sistemas, a pesar de que es bastante popular por ahora. Puedes comprobar tu sistema con este código:

1
if (CRYPT_BLOWFISH == 1) {
2
	echo "Yes";
3
} else {
4
	echo "No";
5
}

Sin embargo, a partir de PHP 5.3, no necesitas preocuparte; PHP viene con esta implementación incorporada.


Conclusión

Este método de hash de contraseñas debería ser lo suficientemente sólido para la mayoría de las aplicaciones web. Dicho esto, no olvides: también puedes exigir a tus miembros que utilicen contraseñas más fuertes, imponiendo longitudes mínimas, caracteres mixtos, dígitos y caracteres especiales.

Una pregunta para ti, lector: ¿cómo haces el hash de tus contraseñas? ¿Puedes recomendar alguna mejora sobre esta implementación?