Bienvenidos a un nuevo post en ByteMind. En el caso de hoy les explicaremos qué es SQL Injection, qué tipos hay de SQL Injection, así como algunos ejemplos comunes, la explicación de cómo encontrar y explotar una vulnerabilidad de este tipo y ciertas contramedidas ante este tipo de ataques.
Pero como primer paso habrá que conocer en que consiste este ataque.
Índice
¿Qué es SQL Injection?
Una inyección SQL (sqli) es una vulnerabilidad de seguridad en la que se permite que un atacante interfiera con las consultas que una aplicación realiza en su base de datos. En general, permite que un atacante vea ciertos datos que no debería de poder recuperar. Esto puede incluir datos pertenecientes a otros usuarios, accesos, estructura de tablas o cualquier otro dato al que la aplicación pueda acceder. En muchos de los casos, un atacante también puede modificar o eliminar dichos datos, provocando cambios persistentes en el contenido o en el comportamiento de la aplicación.
En algunas situaciones, también es posible que un atacante pueda escalar un ataque de inyección SQL para comprometer el servidor subyacente, otra infraestructura de fondo, o realizar ataques de denegación de servicio.
¿Qué impacto puede tener este tipo de ataques?
Un ataque exitoso de inyección SQL puede resultar en un acceso no autorizado a datos sensibles como pueden ser contraseñas de acceso, detalles de tarjetas de crédito o información personal del usuario. En los últimos años, muchas de las violaciones de datos, data leaks, etc… han sido el resultado de una correcta ejecución de ataques de este tipo, lo que ha acabado provocando daños graves a la reputación de las empresas y multas regulatorias por la exposición de los datos.
En algunos casos, un atacante podría obtener un backdoor persistente en los sistemas de una organización, lo que llevaría a un compromiso a largo plazo que puede pasar desapercibido durante un período prolongado en el tiempo.
Tipos de ataques SQL Injection
Principalmente podemos destacar 3 tipos de ataques:
- Ataque por error -> es el ataque más común y el más fácil de explotar ya que es la propia aplicación la que va indicando los errores de la base de datos al realizar las diferentes consultas. Con este error es muy sencillo obtener cualquier dato de la base de datos ya sean estructura, tablas, campos e incluso los datos almacenados.
- Ataque por union -> este tipo de ataque consiste en que el portal devuelva un resultado, y a partir de ahí, añadir al resultado original el resultado de otra query de tal forma que se muestren junto con los datos del portal, los datos sensibles del mismo que no debería de poderse obtener.
- Ataque ciego (blind) -> es el ataque más complicado y el más avanzado, es la última opción cuando ninguno de los ataques anteriores funcionan. En este caso hay que ser muy creativos y se deben de realizar preguntas a la base de datos mediante booleanos, es decir, verdadero o falso, todo aquello que se necesite saber. Aquí podemos separar en dos tipos más:
- Basado en condicionales -> si la consulta está bien mostrará los resultados, sino no mostrará nada.
- Basada en tiempo -> si la consulta es correcta devolverá los resultados a los n segundos, si no no mostrará nada.
Ejemplos de Inyección SQL
Con respecto a las inyecciones SQL hay una gran variedad de vulnerabilidades, ataques y técnicas que surgen en diferentes situaciones.
En este caso vamos a ver los siguientes ejemplos:
- Obtener información de la base de datos
- Recuperación de datos ocultos
- Subvertir la lógica de la aplicación
- Ataques UNION
- Blind SQL Injection (inyección ciega)
A continuación procedemos a explicar cada uno de los ejemplos mencionados.
Obtener información de la base de datos
En la gran mayoría de ocasiones, por no decir siempre, es necesario recopilar información sobre la base de datos. Esto incluye información como el tipo y versión del software y el contenido de la base de datos en lo que se refiere a esquemas, tablas y columnas existentes.
Consultar el tipo y versión de la base de datos
Las diferentes bases de datos proporcionan distintas formas de consultar su versión, a menudo es necesario probar distintas consultas con el fin de detectar una que funcione y con ello, descubrir el software que hay por debajo.
Dependiendo del software para determinar la versión se podrían utilizar las siguientes consultas:
Software | Consulta |
MySQL, Microsoft SQL Server | SELECT @@version |
Oracle | SELECT * FROM v$version |
PostgreSQL | SELECT version() |
Por ejemplo, en caso de realizar un ataque UNION contra una base de datos MySQL la consulta podría ser la siguiente:
1 |
' UNION SELECT @@version -- |
Esto nos puede devolver un resultado como el siguiente, confirmando que la base de datos es un MySQL, o MariaDB como es el caso de este ejemplo:
1 |
10.3.15-MariaDB-1 |
Listar el contenido de la base de datos
La mayoría de bases de datos (a excepción de Oracle) tienen un conjunto de vistas llamado esquema de información que proporciona información sobre la misma.
En el caso de mysql, se puede consultar mediante information_schema.tables para enumerar las tablas existentes, vamos a verlo con un ejemplo. La consulta sería de la siguiente forma:
1 |
SELECT * FROM information_schema.tables |
Y devolvería un resultado similar al siguiente:
1 2 3 4 5 |
TABLE_CATALOG TABLE_SCHEMA TABLE_NAME TABLE_TYPE ===================================================== my_ddbb my_schema table_1 BASE TABLE my_ddbb my_schema table_2 BASE TABLE my_ddbb my_schema table_n BASE TABLE |
La salida anterior indica que las tablas existentes, así como el esquema al que pertenecen. Posteriormente se puede consultar las columnas existentes en cada una de las tablas quedando el comando de la siguiente manera:
1 |
SELECT * FROM information_schema.columns WHERE table_name = 'table_1' |
Esto devolvería una salida similar a la siguiente:
1 2 3 4 5 |
TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME DATA_TYPE ================================================================= my_ddbb my_schema table_1 column_1 int my_ddbb my_schema table_1 column_2 varchar my_ddbb my_schema table_1 column_n varchar |
Este resultado mostraría las columnas existentes en la tabla especificada así como el tipo de datos de cada una de las columnas.
Recuperación de datos ocultos
Para el ejemplo vamos a considerar una aplicación de venta de productos que muestra los mismos en diferentes categorías.
Cuando el usuario hace click, por ejemplo, en la categoría teclados, su navegador solicitará la siguiente URL:
1 |
http://insecure-application.com/products?category=keyboards |
Esta url haría que la aplicación realizase la siguiente consulta SQL para recuperar los productos de dicha categoría:
1 |
SELECT * FROM products WHERE category = 'keyboards' AND published = 1 |
La restricción published = 1 se utiliza para mostrar sólo los productos que se encuentran publicados a la venta y los que no se encuentran tendían el valor de 0.
En este ejemplo la aplicación no implementa ninguna defensa contra este tipo de ataques por lo que se puede construir un ataque modificando el parámetro de la url y quedando de la siguiente forma:
1 |
http://insecure-application.com/products?category=keyboards'-- |
Lo que provocaría que la consulta SQL fuese la siguiente:
1 |
SELECT * FROM products WHERE category = 'keyboards'--' AND published = 1 |
Con el indicador de comentarios — le indicamos que el resto de la consulta se interprete como un comentario y, por lo tanto, no ejecutará el resto de la misma por lo que nos mostraría todos los productos de dicha categoría independientemente de si está publicada su venta o no.
Si intentamos ir más lejos, podemos mostrar todos los productos independientemente de la categoría, para ello vamos a hacer una ejecución simple quedando la url así:
1 |
http://insecure-application.com/products?category=keyboards'+OR+1=1-- |
Y nuestra consulta SQL quedaría de la siguiente forma:
1 |
SELECT * FROM products WHERE category = 'keyboards' OR 1=1--' AND published = 1 |
La consulta modificada devolverá todos los elementos donde la categoría sea keyboards o, donde 1 sea igual a 1. Como 1=1 siempre es verdadero, la consulta devolverá todos los elementos.
Subvertir la lógica de la aplicación
Para el ejemplo vamos a considerar una aplicación que permita a los usuarios iniciar sesión con un nombre de usuario y una contraseña.
Si el usuario envía el nombre usertest y la contraseña userpass, la aplicación verificará las creadenciales con la siguiente consulta SQL:
1 |
SELECT * FROM users WHERE username = 'usertest' AND password = 'userpass' |
Si la consulta devuelve los detalles de un usuario, el acceso es correcto, de lo contrario, este es rechazado.
Aquí, un atacante podría iniciar sesión como cualquier otro usuario sin la necesidad de incluir una contraseña utilizando simplemente la secuencia de comentarios de SQL — para eliminar la verificación de la contraseña en la cláusula WHERE de la consulta.
Vamos a verlo con un ejemplo, en el cual enviamos el nombre de usuario admin’– y una contraseña en blanco, quedando la consulta de la siguiente forma:
1 |
SELECT * FROM users WHERE username = 'admin'--' AND password = '' |
Esta consulta devolvería al usuario cuyo nombre es admin e iniciaría con éxito la sesión del atacante con el mismo.
Ataques UNION
Cuando una aplicación es vulnerable a la inyección SQL y los resultados de la consulta se devuelven dentro de las respuestas de la propia aplicación la palabra clave UNION puede utilizarse para recuperar datos de otras tablas existentes en la base de datos.
La palabra clave UNION permite ejecutar una o más consultas adicionales y mostrar los resultados junto a la consulta original, vamos a verlo con un ejemplo:
1 |
SELECT column1, column2 FROM table1 UNION SELECT column3, column4 FROM table2 |
La consulta anterior nos devolverá un único conjunto de resultados con dos columnas que contendrían los valores de column1 y column2 de la tabla table1 y las columnas column3 y column4 de la tabla table2.
Para que este tipo de consultas funcionen se deben de cumplir dos requisitos:
- Las consultas individuales deben devolver el mismo número de columnas
- Los tipos de datos en cada una de las columnas deben ser compatibles entre las columnas individuales
Estos requisitos implican conocer previamente:
- El número de columnas que devuelve la consulta original
- El tipo de dato que devuelve cada una de las columnas originales con el fin de obtener los resultados de la consulta inyectada
A continuación se explican estos dos últimos puntos.
Determinar el número de columnas
Para detectar el número de columnas que devuelve la consulta original hay dos métodos efectivos.
El primer método consiste en inyectar una serie de cláusulas ORDER BY e incrementar el índice de columna hasta que se produzca un error. Por ejemplo:
1 2 3 |
' ORDER BY 1-- ' ORDER BY 2-- ' ORDER BY n-- |
Estas cargas modifican la consulta original para ordenar los resultados en diferentes columnas. La cláusula se puede especificar por su índice por lo que no es necesario conocer el nombre de cada una de las columnas.
Cuando este índice exceda el número de columnas existentes mostrará un error similar al siguiente:
1 |
The ORDER BY position number 4 is out of range of the number of items in the select list. |
En este caso obtenemos el error en el número 4 por lo que detectamos que existen 3 columnas en la tabla. Pero, puede darse el caso en el que el error aparezca en su respuesta HTTP, podría devolver un error genérico que no mostrase información relevante o, simplemente, podría no devolver ningún error, pero tampoco ningún resultado, por lo que podría dar indicios de este mismo resultado.
El segundo método consiste en enviar una serie de cargas útiles que especifiquen un número de valores nulos, por ejemplo:
1 2 3 |
' UNION SELECT NULL-- ' UNION SELECT NULL,NULL-- ' UNION SELECT NULL,NULL,NULL-- |
Si el número de nulos no corresponde con el número de columnas devolverá un error con el cual podríamos detectar de cuantas columnas existen en la tabla.
El uso de NULL se realiza porque los tipos de datos de cada columna deben de ser compatibles entre las consultas original y las inyectadas. Dado que NULL es convertible a cualquier tipo de dato de uso común aumenta las posibilidades de obtener el recuento de columnas existentes.
Encontrar columnas con un tipo de dato útil
Una vez que se conoce el número de columnas que existen en la tabla, para comprobar el tipo se debe de realizar la prueba con cada una de las columnas para probar el tipo de dato que contienen las mismas, por ejemplo:
1 2 3 |
' UNION SELECT 's',NULL,NULL-- ' UNION SELECT NULL,'s',NULL-- ' UNION SELECT NULL,NULL,'s'-- |
En este caso introducimos un string con el caracter ‘s’ en cada una de las columnas, si el tipo de dato no coincide nos devolverá un error similar al siguiente:
1 |
Conversion failed when converting the varchar value 's' to data type int. |
Pero si el tipo de dato coincide nos devolverá los resultados, por lo que habríamos descubierto el tipo de dato que almacena esa columna.
Blind SQL Injection
La inyección SQL ciega (Blind SQLi) surge cuando una aplicación es vulnerable a la inyección SQL pero sus respuestas HTTP no contienen los resultados de la consulta relevante ni los detalles de ningún error de la base de datos.
En este tipo de vulnerabilidades muchas técnicas como el ataque por cláusula UNION no son efectivas, ya que dependen de los resultados obtenidos de la consulta inyectada. Aunque esto, no quiere decir que no sea posible explotar dicha vulnerabilidad.
Para este tipo de vulnerabilidad se pueden utilizar técnicas de condicionales o de retraso y que explicamos a continuación, aunque debido a la dificultad y el tiempo requerido, es recomendable utilizar herramientas automatizadas como podría ser sqlmap.
Desencadenando respuestas condicionales
Vamos a considerar una aplicación que utiliza cookies para hacer un seguimiento y recopilar información sobre su eso. Las solicitudes llevarán un encabezado similar al siguiente:
1 |
Cookie: TrackId=asdf1234 |
Cuando TrackId procesa una solicitud que contiene la cookie, la aplicación determinará si se trata de un usuario conocido con una sentencia SQL similar a la siguiente:
1 |
SELECT TrackId FROM users WHERE TrackId = 'asdf1234' |
Esta consulta es vulnerable a la inyección SQL, pero la misma no devolverá resultados al usuario. Sin embargo la aplicación se comportará de forma diferente dependiendo de si devuelve o no datos.
Por ejemplo, si la consulta anterior devuelve datos, en pantalla se mostrará el mensaje “Bienvenido”, mientras que si no devuelve datos no mostrará ningún mensaje en pantalla.
Este comportamiento sería suficiente para verificar que existe la vulnerabilidad y recuperar información activando diferentes respuestas condicionales.
Por ejemplo realizamos las siguientes consultas:
1 2 |
abc' UNION SELECT 's' WHERE 1=1-- abc' UNION SELECT 's' WHERE 1=2-- |
El primero de los valores, 1=1, siempre es correcto por lo que la condición inyectada será verdadera y mostrará el mensaje “Bienvenido”, por el contrario el segundo valor devolverá falso, por lo que no se mostrará ningún mensaje.
Por ejemplo, vamos a suponer que hay una tabla llamada users que contiene los campos username y password y que existe un usuario llamado admin.
Comenzaremos lanzando la siguiente consulta:
1 |
abc' UNION SELECT 's' FROM users WHERE username = 'admin' AND SUBSTRING(password, 1, 1) > 'm'-- |
La consulta anterior devuelve el mensaje “Bienvenido” por lo que conocemos que el primer carácter del campo password es superior a la letra m.
Posteriormente, lanzamos la siguiente consulta:
1 |
abc' UNION SELECT 's' FROM users WHERE username = 'admin' AND SUBSTRING(password, 1, 1) < 'p'-- |
Esta consulta no devuelve ningún mensaje por lo que conocemos que el primer carácter se encuentra entre la letra m y la letra p.
Lanzamos una tercera consulta:
1 |
abc' UNION SELECT 's' FROM users WHERE username = 'admin' AND SUBSTRING(password, 1, 1) = 'o'-- |
En este caso hemos descubierto que el carácter ‘o’ nos devuelve el mensaje “Bienvenido” por lo que ya hemos conocido el primer carácter del campo password.
De esta forma podemos conocer el valor completo del campo password, aunque eso sí, el tiempo necesario para completar un ataque es muy superior al de cualquier otra técnica de inyección.
Desencadenar errores de SQL mediante respuestas condicionales
Utilizando el mismo ejemplo anterior, hay ocasiones en el que una inyección no muestra ningún cambio en la página, haciendo mucho más laborioso llevar a cabo este tipo de ataques.
En esta situación, a menudo, es posible inducir a la aplicación a devolver respuestas activando errores de SQL. Esto implicará modificar la consulta para que cause un error en la base de datos si la condición es verdadera, pero no si la condición es falsa, ayudando a detectar los mismos y obtener algo de información.
Para ello vamos a verlo con un ejemplo:
1 2 |
abc' UNION SELECT IF(1=1, 1/0, NULL)-- abc' UNION SELECT IF(1=2, 1/0, NULL)-- |
En las anteriores consultas, en el primer caso se evaluará como 1/0 por lo que devolverá un error dándonos información del resultado, mientras que en la segunda evaluará NULL por lo que no dará ningún error en la aplicación.
Con esta técnica, ahora podemos recuperar los datos de la misma forma que lo hicimos en el anterior ejemplo, con una consulta similar a la siguiente:
1 |
abc' UNION SELECT IF((SELECT 's' FROM users WHERE username = 'admin' AND SUBSTRING(password, 1, 1) > 'p'), 1/0, NULL)-- |
Desencadenando retrasos
En el caso de que ninguna de las opciones anteriores nos de algún resultado positivo de la vulnerabilidad, otra de las técnicas utilizadas es la activación de retardos de tiempo condicionalmente. Debido a que las consultas, generalmente, son procesadas de forma sincrona por la aplicación, retrasar la ejecución también retrasará la respuesta por lo que puede darnos información al respecto.
En el caso de MySQL podríamos activar un retardo de la siguiente forma:
1 2 |
' SELECT IF(1=1, sleep(10), NULL) ' SELECT IF(1=2, sleep(10), NULL) |
Con esta técnica podemos recuperar estas entradas al activar un retraso, por lo que si se activa el retraso sabremos que la condición es verdadera.
De esta forma podríamos añadir nuestra inyección de la siguiente forma:
1 |
' SELECT IF((SELECT password FROM users WHERE username = 'admin' AND SUBSTRING(password, 1, 1) = 'o'), sleep(10), NULL) |
Y de esta forma ir obteniendo información al respecto.
Contramedidas
La mayoría de las vulnerabilidades de inyección SQL se deben a una incorrecta concatenación de los datos, por ejemplo en el siguiente código no se comprueba la entrada introducida:
1 |
$a = "SELECT * FROM users WHERE username = '" + input + "'"; |
El método correcto sería el uso de sentencias preparadas y consultas parametrizadas, que dependerán del lenguage de programación y de la librería utilizada, además de un saneamiento del texto introducido, por ejemplo mediante expresiones regulares que limiten estos caracteres, la verificación del tipo de dato introducido, etc.
Para ello la gran mayoría de librerías en los diferentes lenguages existentes ya proporcionan funciones creadas para este fin, haciéndo más fácil implementar estos métodos de protección.
Hasta aquí es todo por ahora. Espero les haya sido de utilidad en su día a día o en sus desarrollos y como siempre, cualquier duda, aporte o cometario es bienvenida.