La última vez, para comprobar la
vulnerabilidad de SQL Injection de refbase, obtuvimos unos cuantos hashes de contraseñas. Hoy he añadido algunas cuentas de acceso a la aplicación con objeto de seguir jugando. Volviendo a relizar la misma inyección de SQL he obtenido los siguientes pares de correos electrónicos y hashes (como es costumbre, van separados por el carácter ":"):
user@refbase.net:usLtr5Vq964qs
bb@cc.com:bbTdyOM4g6r9Q
a@b.c:a@MwrmlI6E95E
a!dsf@t.com:a!72jMCWwO03E
¡Uy, qué cortitos son estos hashes! Sólo 13 caracteres. Esto tiene mala pinta.
Veamos los scripts de la aplicación que gestionan las constraseñas. El primero de ellos, en el que se calcula el hash y se almacena en la base de datos es "user_validation.php", dentro del cual podemos encontrar el siguiente fragmento de código:
$salt = substr($formVars["email"], 0, 2);
// Create the encrypted password
$stored_password = crypt($formVars["loginPassword"], $salt);
// Update the user's password within the auth table
$query = "UPDATE $tableAuth SET "
. "password = " . quote_smart($stored_password)
. " WHERE user_id = " . quote_smart($userID);
Y la comprobación realizada en los intentos de inicio de sesión puede encontrarse en "user_login.php":
$salt = substr($loginEmail, 0, 2);
// Encrypt the loginPassword collected from the challenge (so that we can compare it to
// the encrypted passwords that are stored in the 'auth' table)
$crypted_password = crypt($loginPassword, $salt);
¡Sí que tiene mala pinta! Se emplea la función "crypt" con una "sal" de sólo dos caracteres. Veamos lo que dice el
manual de PHP sobre ella. Pongo puntos suspensivos para omitir parte del texto y no hacerlo demasiado largo:
crypt() devolverá el hash de un string utilizando el
algoritmo estándar basado en DES de Unix o
algoritmos alternativos que puedan estar disponibles en el sistema.
Algunos sistemas operativos soportan más de un tipo de hash. De
hecho, a veces el algoritmo estándar basado en DES es sustituído por un
algoritmo basado en MD5. El tipo de hash se dispara mediante el argumento salt. ... Si no se proporciona una sal, PHP
autogenerará o una sal estándar de dos caracteres (DES), o una de doce
caracteres (MD5), dependiendo de la disponibilidad de la función crypt() de MD5.
...
En sistemas donde la función crypt() soporta múltiples
tipos de hash, las siguientes contantes se establecen en 0 o 1,
dependiendo de que si el tipo dado está disponible:
-
CRYPT_STD_DES
... salt de dos caracteres
del alfabeto "./0-9A-Za-z".
-
CRYPT_EXT_DES
...el "salt" es un
string de 9 caracteres que consiste en un guión bajo seguido de 4 bytes del conteo de iteraciones
y 4 bytes del salt.
-
CRYPT_MD5
... salt de doce caracteres comenzando con
$1$
-
CRYPT_BLOWFISH
... salt como
sigue: "$2a$", "$2x$" o "$2y$", un parámetro de coste de dos dígitos, "$", y
22 caracteres del alfabeto "./0-9A-Za-z".
-
CRYPT_SHA256
...
salt de dieciséis caracteres
prefijado con $5$.
-
CRYPT_SHA512
... salt de dieciséis caracteres
prefijado con $6$
O sea: que al usar dos caracteres para la "sal" se obliga a "crypt" a usar DES estándar que, como se vio anteriormente, genera hashes de 13 caracteres de los que los 2 primeros corresponden a la salt. Por decirlo de forma suave, para esto de las contraseñas se queda bien cortito.
Además, los dos caracteres deben pertenecer al alfabeto
"./0-9A-Za-z". Dos signos de puntuación, diez dígitos, veintiséis letras mayúsculas y otras veintiséis minúsculas. En total, un alfabeto de 64 caracteres. Y, en definitiva, sólo 64x64 = 4096 combinaciones posibles que, con el estado actual de la tecnología, resultan insuficientes para hacer inviables los ataques con Rainbow Tables.
Pero volvamos a observar los hashes obtenidos mediante SQL Injection. Los dos últimos contienen caracteres incorrectos para una "sal" de las que acabamos de describir:
a@b.c:a@MwrmlI6E95E
a!dsf@t.com:a!72jMCWwO03E
La "sal" del primero de los hashes es "a@". Y la del segundo, "a!". Ni "@" ni "!" son caracteres incluídos en el alfabeto
"./0-9A-Za-z". Entonces... ¿qué hace PHP con ellos?
Aunque el manual indique la función fallará si encuentra este tipo de caracteres, la verdad es que no lo hace. Y la prueba es que ahí están esos hashes. En PHP 7 hace que se muestre un warning, pero poco más. Las distintas implementaciones de DES estándar responden cada una a su manera a estas situaciones pero lo habitual (y lo que PHP hace) es sustituir cada uno de estos caracteres incorrectos por otro que sí sea válido. La forma en que se realiza este "mapeo" puede variar dependiendo de cada software, de modo que realicé un script PHP que se encargara del trabajo sucio.
Este script, cuyo listado aparece al final del post, determina mediante pruebas exhaustivas el mapeo de caracteres incorrectos a caracteres correctos y, si es necesario, corrige la parte de la "sal" del hash para que no quede en él nada raro. El resultado que obtuve fue
user@refbase.net:usLtr5Vq964qs
bb@cc.com:bbTdyOM4g6r9Q
a@b.c:aGMwrmlI6E95E
a!dsf@t.com:an72jMCWwO03E
Y hay otro problema. Este mecanismo de creación de hashes trunca la contraseña en el octavo carácter. O sea, que de nada sirve poner contraseñas de 9 o más caracteres si uno quiere mejorar su calidad. Para comprobarlo, tomemos el siguiente script PHP de usar y tirar, que obtiene un hash para tres cadenas que comienzan con los mismos caracteres: una de longitud 7, otra de longitud 8 y una última de longitud 12:
<?php
function imprime_fila($texto, $salt, $valor) {
$dato = $valor ? $valor : crypt($texto, $salt);
print "
<tr>
<td>$texto</td>
<td>$salt</td>
<td>$dato</td>
</tr>
";
}
print "<table border=1>";
imprime_fila("TEXTO", "SALT", "HASH");
$base = "1234567";
foreach (["", "8", "80abc"] as $texto) {
imprime_fila($base . $texto, "ab");
}
print "</table>";
?>
En la salida producida puede observarse que el hash para "12345678" es el mismo que para "1234567890abc". O sea: que el propio sistema se encarga de deteriorar las contraseñas que recibe si estas son demasiado buenas.
¿Que si todo esto es tan importante? Para comprobarlo, guardemos estos datos en un fichero llamado "refbase.txt" y pasémoslo por
John The Ripper. Vaya por delante que no voy a utilizar un equipo demasiado potente ni me he molestado en optimizar el uso de este crackeador de hashes.
En primer lugar, realicé sólo un ataque de diccionario mediante lista de palabras para coger pronto las "frutas que están al alcance de la mano":
|
Ataque de lista de palabras a hashes DES estándar |
En menos de un segundo, ya tengo las primeras tres contraseñas. Para ver si obtengo la cuarta, toca probar a dejar a John The Ripper usar sus propias reglas de "mejora" del diccionario:
|
Cuarenta y un segundos |
Sólo ha hecho falta poco más de cuarenta segundos para lograrlo. Bueno, la verdad es que las contraseñas no eran demasiado buenas y no ha sido necesario utilizar la artillería pesada, pero ya tenemos una primera medida.
Desde luego, mejor habrían hecho en buscar un algoritmo apropiado para el cómputo del hash. Esto se lo comenté en su día, hace más de un año, con los desarrolladores de refbase y me respondieron que eran conscientes del problema y que estaban estudiando dejar de usar la función "crypt" y sustituirla por "password_hash".
Deben ser muy estudiosos, porque aún parecen seguir estudiándolo. En todo caso, que estuvieran al tanto de la solución me reafirma en mi creencia de que no es que esto de la seguridad no les preocupe, sino que no tienen el tiempo ni los recursos necesarios para mantener un software tan complejo como refbase.
Sobre "password_hash" se habla en la propia página de "crypt" del manual de PHP :
password_hash() utiliza un hash fuerte, genera una sal fuerte, y aplica los redondeos necesarios automáticamente. password_hash() es una envoltura simple de crypt() compatible con los hash de contraseñas existentes. Se aconseja el uso de password_hash().
Y,
si nos vamos a su propia documentación, podemos leer:
Actualmente se admiten los siguientes algoritmos:
-
PASSWORD_DEFAULT
- Usar el algoritmo bcrypt (predeterminado a partir de PHP 5.5.0).
Observe que esta constante está diseñada para cambiar siempre que se añada un algoritmo nuevo y más fuerte
a PHP. Por esta razón, la longitud del resultado de usar este identificador puede cambiar con el tiempo.
Por lo tanto, se recomienda almacenar el resultado en una columna de una base de datos que pueda
apliarse a más de 60 caracteres (255 caracteres sería una buena elección).
-
PASSWORD_BCRYPT
- Usar el algoritmo CRYPT_BLOWFISH
para
crear el hash. Producirá un hash estándar compatible con crypt() utilizando
el identificador "$2y$". El resultado siempre será un string de 60 caracteres, o FALSE
en caso de error.
Bueno, BCRYPT es, desde luego, mucha mejor opción que DES estándar. No es perfecta, y trunca las contraseñas a 72 caracteres:
Precaución
El uso de PASSWORD_BCRYPT
como el
algoritmo resultará
en el truncamiento del parámetro password
a
un máximo de 72 caracteres de longitud.
Y alguien apunta en los comentarios que también trunca la contraseña en el primer carácter nulo que encuentre:
Please note that password_hash will ***truncate*** the password at the first NULL-byte.
Pero creo que se ven claramente las ventajas frente a DES estándar. Además, para verificar posteriormente las contraseñas contra los hashes calculados con "password_hash", existe otra función, "
password_verify" que, como se indica en su
documentación en inglés, tiene mecanismos de protección frente a
ataques de medida de tiempo. Ya sabe: esos que tratan de determinar cuántos caracteres del inicio, ya sea de la contraseña o del hash, has acertado midiendo el tiempo que el programa tarda en comparar las cadenas:
This function is safe against timing attacks.
Preparé, pues, un script "rápido y sucio" que calcula los hashes usando "password_hash". Mejor no ponerle ninguna "sal" y dejar que se genere una al azar:
<?php
print"user@refbase.net:". password_hash("start", PASSWORD_DEFAULT) . "<br>";
print"bb@cc.com:". password_hash("test", PASSWORD_DEFAULT) . "<br>";
print"a@b.c:". password_hash("a", PASSWORD_DEFAULT) . "<br>";
print"a!dsf@t.com:". password_hash("p1968", PASSWORD_DEFAULT) . "<br>";
?>
Y el resultado obtenido esta vez fue (si tú lo pruebas, te saldrán hashes distintos por lo de la "sal" aleatoria):
user@refbase.net:$2y$10$g1vrkHhlkuFy.c4njRQVFuHvL9cB8wc8GTmyykRYNTwUDTFSAIyFm
bb@cc.com:$2y$10$Zi1mUOzPS7VolHJc7vLpReJQdbR7eoErCHQhqiizEDKerPMaJCMmi
a@b.c:$2y$10$/0GiqTFxtA2NJXtRc4pvV.niY940ClYAnxelPsnVtuW1chBFgLnHm
a!dsf@t.com:$2y$10$mmJeQXlay3dv1wysiFPvBeCL0j1U81SLrs/TiFV0tfOy/97DKSNgO
De modo que... a guardarlo en "refbase2.txt" y someterlo a un ataque de diccionario por lista de palabras:
|
Esto tarda bastante más |
Lo que antes llevó menos de un segundo ahora lleva casi cuatro minutos. Y quedan aún las contraseñas más difíciles. Si observamos los indicadores que da John de Ripper vemos que, por tomar uno cualquiera de ellos, el número de contraseñas probadas por segundo se ve reducido en una proporción aproximada de 1 a 7600. Creo que poco más habría que añadir.
Y la pena es que no haría falta que modificaran demasiado el código fuente de refbase para arreglar todo esto.
Bueno, aquí lo dejo por hoy. Nos vemos por aquí pronto, espero.
PD.: Ahí dejo el script que corrige las "sales" para los hashes DES estándar:
<?php
$password="user@refbase.net:usLtr5Vq964qs
bb@cc.com:bbTdyOM4g6r9Q
a@b.c:a@MwrmlI6E95E
a!dsf@t.com:a!72jMCWwO03E";
function crea_conversor() {
$cadena = "adsfadf !!! adfadsf!!!";
$validos = [".", "/"];
for ($i=48;$i<=57;$i++) {
$validos[] = chr($i);
}
for ($i=65;$i<=90;$i++) {
$validos[] = chr($i);
}
for ($i=97;$i<=122;$i++) {
$validos[] = chr($i);
}
$res = [];
for ($i=0; $i<255; $i++) {
$c = chr($i);
$cod = substr(crypt($cadena, "a$c"), 2);
foreach ($validos as $v) {
$cod2 = substr(crypt($cadena, "a$v"), 2);
if ($cod == $cod2) {
$res[$c] = $v;
}
}
}
return $res;
}
function procesa_hash($h, $conv) {
return
$conv[substr($h,0,1)] .
$conv[substr($h,1,1)] .
substr($h,2);
}
$conv = crea_conversor();
$lineas = preg_split("/[\r\n]+/", $password);
foreach ($lineas as $l) {
$partes = explode(":", $l);
print htmlspecialchars($partes[0] . ":" . procesa_hash($partes[1], $conv)). "<br>";
}