Andaba un poco perdido tras haber terminado el Holiday Hack cuando un tweet de
Daniel Garcia (a.k.a.
@danigargu) dirigió mi atención hacia la web de
overthewire
y la lista de wargames que tienen publicados. De todos los disponibles
el primero que llamó mi atención por su temática relacionada con la
seguridad de las aplicaciones web en la parte del servidor fué
Natas, y como "mente ociosa solo trae malos pensamientos" allá que me puse con ello.
Realmente os animo a jugar: es entretenido, el nivel es bastante
asequible y enseña los errores que no se deberían cometer a la hora de
asegurar una aplicación web. De hecho si he logrado convencerte no
deberías seguir leyendo esto, ya que aunque no voy a detallar los pasos
para completar el wargame si me centraré en una de las pruebas, lo que
personalmente hice para pasarla y lo que he aprendido por el camino.
Toma de contacto
Para acceder a los distintos niveles tenemos que disponer de un usuario,
que siempre es natasX siendo X el número correspondiente al nivel, y
una contraseña que habremos obtenido en el nivel anterior. Concretamente
comentaré acerca del nivel 15 y el método seguido para sacar la
contraseña de acceso al nivel 16, ya que el usuario lo tenemos claro:
natas16.
Para llegar a éste nivel hemos tenido que valernos previamente de una
SQL injection
utilizando las tan denostadas por unos y queridas por otros comillas,
comillas dobles concretamente. Además durante el proceso de prueba/error
hemos descubierto que la base de datos funcionando en el backend es
MySQL.
Cuando accedemos al nivel 15 nos encontramos un formulario que permite
introducir un nombre de usuario y comprobar si está dado de alta en la
base de datos. También tenemos un enlace para ver el código fuente y por
ende la lógica de la aplicación, pero como que somos muy chulitos vamos
a probar a pelo. Primera prueba lógica que se me ocurre, probar con un
usuario que exista, así que utilizando natas16 obtenemos:
This user exists.
Vale, vamos bien. Segunda prueba lógica, probar con un usuario que no exista, así que probaremos con
foobar que queda como que muy profesional y casi seguro que no va a estar. Resultado:
This user doesn't exist.
Y ahora la refinitiva, vamos a meter unas comillas dobles para ver si el
formulario, como el del nivel anterior, también es vulnerable a una SQL
injection:
Error in query.
Ya la hemos jodío, parece que los errores producidos por carácteres
"indeseables" son capturados por la aplicación. ¿Aún así seguirá siendo
vulnerable? Recapitulemos, la lógica me inclina a pensar que la consulta
SQL ejecutada debe ser algo como:
select * from users where username = "loqueyometo";
así que si utilizo un usuario existente más una expresión verdadera y
exijo que ambas se cumplan para que la consulta sea válida y el
formulario es vulnerable a una SQL injection debería devolverme la
cadena "
This user exists".
Como siempre, se entiende mejor viéndolo, en este caso la cadena a
inyectar; el comentario final, los dos guiones, únicamente inhabilitan
el resto de la consulta y los incluyo sólo por si realmente hay algo más
detrás de lo que imagino que hay:
natas16" and 1 = 1 --"
con lo que la supuesta consulta SQL final quedaría:
select username from users where username = "natas16" and 1 = 1 --""
Lo pruebo y obtengo como resultado la cadena esperada, "This user
exists". Por confirmar voy a probar ahora con un usuario existente más
una expresión falsa volviendo a exigir que se cumplan ambas condiciones
para que la consulta sea válida. Dado que una de ellas ya se de antemano
que es falsa debería obtener como resultado la cadena "This user
doesn't exist".
natas16" and 1 = 2 --"
con lo que la consulta SQL quedaría:
select username from users where username = "natas16" and 1 = 2 --""
Obtengo la cadena "
This user doesn't exist" así que confirmo mis suposiciones: es una blind SQL injection.
¿Y eso que es?
Pues es una SQL injection de las de toda la vida sólo que en este caso
no podemos obtener directamente lo que buscamos ya que la aplicación no
nos devuelve ningun campo sobre el que tengamos control y podamos
utilizar para mostrar resultados. Pero no está todo perdido ya que como
obtenemos resultados diferentes para consultas correctas e incorrectas
aún podemos inferir lo que buscamos.
Podemos intentar obtener la longitud de la contraseña para el usuario
natas16 resultado que utilizaremos posteriormente para averiguarla.
Existe una función de MySQL, length(str), que nos devuelve el tamaño de
una cadena que se le pase como argumento. Si a eso le sumamos que
sabemos los resultados para consultas correctas, llamemos a éste
resultado flag OK, e incorrectas, llamemos a éste resultado flag NOK,
podemos ir probando tamaños hasta que la aplicación nos devuelva NOK:
natas16" and (SELECT length(password) FROM users where username='natas16') > 1 --"
natas16" and (SELECT length(password) FROM users where username='natas16') > 2 --"
natas16" and (SELECT length(password) FROM users where username='natas16') > 3 --"
...
que producirán, siempre supuestamente, las siguientes consultas:
select * from users where username = "natas16" and (
select length(password) from users where username='natas16') > 1 --""
select * from users where username = "natas16" and (
select length(password) from users where username='natas16') > 2 --""
select * from users where username = "natas16" and (
select length(password) from users where username='natas16') > 3 --""
...
Llegará un momento en que el valor que indiquemos no será mayor,
obtendremos el flag NOK, y podremos inferir que la longitud se
corresponderá justo con el valor probado en la consulta anterior.
Disponiendo ahora de la longitud pasaremos a obtener la contraseña.
Utilizaremos ahora dos funciones más ofrecidas por MySQL. La primera,
ascii(chr), nos permite obtener el valor numérico correspondiente al
carácter que se le pase como argumento, y la segunda,
substring(str,pos,len), nos permite extraer tantos caracteres como se le
indiquen, len, desde una posición determinada, pos. Lo que haremos:
- Obtener un carácter de la contraseña mediante la función substring.
- Convertirlo a su equivalente numérico utilizando la función ascii.
- Compararlo con el primer valor de una serie de caracteres (letras mayúsculas y minúsculas, y números):
- Si ambos caracteres coinciden, obtener el siguiente carácter y repetir el proceso.
- Si los caracteres no coinciden, comparamos con el siguiente valor y así hasta que coincidan.
- Reiniciar el proceso para obtener el siguiente carácter.
- Cuando tengamos tantos caracteres como larga sea la contraseña habremos terminado.
Un ejemplo de la cadena a inyectar para comparar el primer carácter de
la contraseña con el valor numérico en formato decimal extraído de la
tabla ascii para el carácter 0 sería:
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),1,1)) = 50 --"
que se correspondería con la siguiente consulta SQL generada por la aplicación:
select * from users where username = "natas16" and
ascii(substring((SELECT password FROM users
where username='natas16'),1,1)) = 50 --"
Si el primer carácter de la contraseña es igual a "0" la aplicación nos devolverá el flag OK, "
This user exists",
y podremos seguir probando con el segundo caracter de la contraseña. Si
por el contrario el primer carácter de la contraseña no es "0" la
aplicación nos devolverá el flag NOK, "
This user doesn't exist", y tendremos que seguir probando con otro valor hasta dar con el adecuado, y así sucesivamente.
Hacer esto a mano es de locos, asi que vamos a tirar de python :)
Que trabaje la maquina por mí
La estructura original del primer script era mucho más fea, además de no
devolver mucho feedback al usuario lo que podía llevar a pensar, y de
hecho era lo que yo pensaba nada más escribirlo y probarlo, que una de
dos, o se había quedado colgado o simplemente no funcionaba; aunque sí
funcionaba y me ayudó a superar la prueba. Sumémosle a esto último que
me quedé atascado bastante tiempo en el último nivel hasta que hice
trampas y busqué la solución, la cual encontré
aquí. Resumiendo, que me gustó mucho la forma en que
Julien Voisin
iba presentando los resultados y había estructurado su script para el
nivel 16, así que adapté el mío para que se pareciese y quedara más
chulo. Para hacer una estimación del tiempo que tardaba en obtener la
password utilizaba el siguiente batch:
@echo off
setlocal
echo %time%
echo ---------------------
cmd /c %*
echo ---------------------
echo %time%
que pasándole como parámetros la cadena de ejecución del script me dió como resultado:
C:\>timer.cmd python blindsqli.py
16:15:13,65
---------------------
Url: http://natas15.natas.labs.overthewire.org
[*] Authenticating
[*] Guessing password length...
[*] Password lenght: AQUIVAELTAMANYO
[*] Guessing password...
[*] Password guessed!
Password: AQUIVALAPASSWORD
---------------------
16:22:07,97
El script cumplía con su objetivo aunque no destacaba precisamente por
su velocidad, así que decidí tratar de mejorarlo leyendo el más que
recomendable "
SQL injection: attacks and defense", donde se mencionan dos métodos mucho más eficientes para obtener los datos deseados: búsqueda binaria y bit-a-bit.
Resulta que el algoritmo que había utilizado y que se encarga de
recorrer secuencialmente el contenido de un conjunto de valores hasta
dar con el deseado, se conoce como búsqueda lineal y es matemáticamente
mucho más ineficiente que la búsqueda binaria; para que luego digan que
las matemáticas no sirven para nada (nota mental: estudiar algoritmia).
No me detendré en el método de la búsqueda binaria, que ya hay mucho
material, pero sí en el de bit-a-bit, ya que me pareció mucho más fácil
de implementar y a priori mucho más rápido que los otros dos. Me
explicaré...(o al menos lo intentaré).
Como ya sabrás, cada carácter que forma parte de la contraseña se
representa internamente como un byte el cual a su vez está dividido en 8
bits con sólo dos valores posibles, 0 ó 1. La idea es utilizar alguna
de las operaciones binarias de MySQL para obtener el valor de cada uno
de esos bits de forma independiente. Ésto nos garantizará que para cada
carácter se realizarán un mínimo y un máximo de 8 consultas. Por lo
tanto las cadenas que inyectaremos por cada carácter (aumentando N desde
1 hasta la longitud de la contraseña) serán:
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 128 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 64 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 32 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 16 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 8 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 4 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 2 --"
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 1 --"
Cada una de estas consultas, en caso de ser cierta nos devolverá el flag OK, "
This user exists", y por lo tanto el valor del bit será igual a 1, o por el contrario devolverá el flag NOK, "
This user doesn't exist",
siendo entonces 0 el valor correspondiente al bit. Cuando tengamos los 8
bits tendremos por tanto el valor del byte, el cual interpretaremos
como un caracter ASCII.
No sé si me he explicado muy bién, así que
aquí
os dejo el script definitivo para que le echéis un vistazo. Observaréis
como también he eliminado la obtención previa de la longitud de la
contraseña para ahorrar así algunas consultas más. Para comprobar si
hemos llegado al final de la misma comparo el byte inferido con el valor
para la cadena vacía, que sería el resultado obtenido al ejecutar la
funcion substring pasándole como parámetro un valor de posición
inexistente ya que la cadena es más corta. Un ejemplo de su ejecución
haciendo nuevamente un cutre "benchmark":
C:\>timer.cmd python blindsqli.py
17:12:26,00
---------------------
Url: http://natas15.natas.labs.overthewire.org
[*] Authenticating
[*] Guessing password...
[*] Password guessed!
Password: AQUIVALAPASSWORD
---------------------
17:14:03,11
Al menos para este caso el script es mucho más rapido. Quizás utilizando
adecuadamente threads para comparar simultáneamente los 8 bits por cada
byte iría aún más rápido, aunque en las pruebas que yo he hecho sin
tener ni idea la mejoría no ha sido muy notable que digamos :(
En breve más y mejor, espero ;)
Enlaces de interés
Advanced SQL Injection in SQL Server Applications
(more) Advanced SQL Injection
Hackproofing MySQL
Data-Mining With SQL Injection and Inference
Time-Based Blind SQL Injection with Heavy Queries
From SQL injection to shell
SQL Injection Online Cheatsheet
MySQL: funciones para cadenas de caracteres
SQL Injection: attacks and defense
Justin Clarke
Syngress
Fuente: http://neosysforensics.blogspot.com/2013/01/jugando-si-se-aprende-advierto-nivel.html
Inicio wargame: http://overthewire.org/wargames/natas/