Ghost AcademyGhostOffensive · Ghost Academy
GhostOffensiveMódulo 07Con laboratorio

Inyección SQL

La inyección SQL ocurre cuando una aplicación construye una consulta a la base de datos pegando texto del usuario directamente en ella, en lugar de tratarlo siempre como un dato. El resultado es que el usuario puede cambiar la pregunta que se le hace a la base de datos, no solo la respuesta.

Objetivos de la unidad

1. El mecanismo: código y datos mezclados

Una base de datos recibe texto y lo convierte en un plan de ejecución en dos fases: primero analiza (parsea) la cadena para entender su estructura —qué es una palabra clave, qué es un nombre de tabla, qué es un valor literal— y luego la ejecuta. El problema nace cuando la aplicación construye esa cadena pegando la entrada del usuario:

kali@lab
$ query = "SELECT * FROM usuarios WHERE usuario='" + usuario + "' AND contrasena='" + contrasena + "'"

Cuando el usuario escribe algo dentro de usuario, ese texto cae dentro de las comillas, es decir, en el contexto de un literal de cadena. Mientras se quede ahí, es un dato inofensivo. La comilla simple es la frontera de ese contexto: en el momento en que el usuario incluye una comilla, cierra la cadena antes de tiempo y todo lo que escriba después el parser lo interpreta como código SQL, no como dato. Esa es toda la vulnerabilidad, en una frase: el atacante consigue escribir en el contexto de código un texto que la aplicación creía que iba a quedarse en el contexto de dato.

cómo lo ve el parser
usuario = O'Brien   (una comilla legítima en un apellido ya rompe la consulta: primer síntoma)
SELECT ... WHERE usuario='O'Brien' ...
                          └ cadena ─┘└─ ¿código? ─┘  ← el parser se pierde aquí

Por eso el primer indicio de una inyección no es un exploit sofisticado, sino que una simple comilla provoca un error o un cambio de comportamiento.

2. Detección sistemática

Antes de explotar nada hay que confirmar que el parámetro es inyectable y en qué contexto cae. Dos pruebas básicas:

PruebaQué revela
Añadir una comilla 'Si aparece un error o la página se rompe, la entrada llega sin escapar a la consulta.
Comparar ' OR '1'='1 vs ' OR '1'='2Si la respuesta cambia entre una condición verdadera y una falsa, controlas la lógica de la consulta.

El contexto importa porque cambia el payload:

Y el comentario con el que anulas el resto de la consulta depende del motor: -- (con espacio final, estándar), # (MySQL), /* ... */ (intercalado). Ese detalle del espacio tras -- es una causa habitual de payloads que "deberían funcionar" y no lo hacen.

3. Bypass de autenticación, carácter a carácter

El caso escuela. La consulta de login es SELECT * FROM usuarios WHERE usuario='X' AND contrasena='Y'. Introduciendo como usuario admin' -- :

kali@lab
$ SELECT * FROM usuarios WHERE usuario='admin' -- ' AND contrasena=''

Desglose del payload: la comilla cierra el literal; admin ya estaba dentro; -- convierte en comentario todo lo que sigue, incluida la comprobación de la contraseña. La consulta se reduce a "dame el usuario admin", sin validar clave. Una variante cuando no conoces ningún usuario es la tautología ' OR 1=1 -- : la condición 1=1 es siempre verdadera, así que la consulta devuelve la primera fila de la tabla (a menudo, un administrador).

4. Identificar el motor y el esquema

Para extraer datos con precisión necesitas saber qué base de datos hay detrás y cómo se llaman sus tablas y columnas. Dos vías:

kali@lab
$ ' UNION SELECT table_name, column_name FROM information_schema.columns -- 
Necesitas...MySQL / PostgreSQL / MSSQLSQLite
Versiónversion() / @@versionsqlite_version()
Listar tablasinformation_schema.tablessqlite_master
Comentario-- o #--

5. UNION: leer otra tabla

UNION SELECT pega los resultados de una segunda consulta debajo de la primera. Para que el motor lo acepte, la segunda consulta debe tener exactamente el mismo número de columnas y tipos compatibles. Por eso la explotación con UNION tiene siempre dos pasos previos:

  1. Contar columnas. Se incrementa ORDER BY 1, ORDER BY 2... hasta que salta un error: el último número que funcionó es el número de columnas. (Alternativa: UNION SELECT NULL, UNION SELECT NULL,NULL... hasta que deja de dar error.)
  2. Encontrar una columna "visible". Se colocan marcadores (UNION SELECT 'a','b','c') para ver cuál de ellos aparece en la página: ahí es donde hay que poner el dato que se quiere extraer.
kali@lab
$ ' UNION SELECT usuario, contrasena, NULL FROM usuarios -- 

Con eso, y el esquema del paso 4, se extrae cualquier tabla a la que tenga acceso la cuenta con la que la aplicación se conecta a la base de datos (de ahí la importancia del mínimo privilegio: si esa cuenta no puede leer la tabla de contraseñas, el UNION tampoco).

6. Inyección ciega booleana: extraer sin ver

Muchas aplicaciones no muestran ni datos ni errores: solo se comportan de forma distinta según si la consulta interna es verdadera o falsa (por ejemplo, "usuario disponible" / "en uso"). Ese único bit —verdadero o falso— basta para reconstruir cualquier dato, carácter a carácter, haciendo preguntas de sí/no sobre él.

La pieza clave es aislar un carácter y compararlo: SUBSTRING(dato,1,1) saca el primer carácter y ASCII(...) lo convierte en número para poder comparar con <, >, =. Ejemplo de una pregunta:

kali@lab
$ ' AND ASCII(SUBSTRING((SELECT contrasena FROM usuarios WHERE usuario='admin'),1,1)) > 109 -- 

Si la página responde "verdadero", el primer carácter de la contraseña tiene un código ASCII mayor que 109 (la letra m). Repitiendo la pregunta se acota el valor. Lo ingenuo es probar de la a a la z (hasta 26 preguntas por carácter); lo eficiente es la búsqueda binaria: preguntar siempre por la mitad del rango restante reduce cada carácter a unas 7 preguntas (log₂ de 128). Multiplícalo por la longitud del dato y entiendes por qué esto se automatiza siempre.

7. Inyección ciega por tiempo: cuando ni el sí/no se ve

A veces ni siquiera hay una diferencia visible entre verdadero y falso. Entonces se usa un canal aún más sutil: el tiempo. Se inyecta una condición que, solo si es verdadera, obliga al motor a esperar. Si la respuesta tarda, la condición era cierta:

kali@lab
$ ' AND IF(ASCII(SUBSTRING(clave,1,1))>109, SLEEP(3), 0) -- 

Cada motor tiene su función de retardo: SLEEP() en MySQL, pg_sleep() en PostgreSQL, WAITFOR DELAY en MSSQL. SQLite no tiene ninguna, así que ahí el retardo se fuerza con una consulta deliberadamente costosa (por ejemplo una recursiva de millones de iteraciones): el mismo principio, medido en segundos. Es la técnica más lenta de todas —cada bit cuesta una espera real— pero funciona cuando ninguna otra lo hace.

8. Inyección de segundo orden

No toda inyección se dispara en el momento de enviar el dato. En la de segundo orden, la aplicación guarda el dato de forma segura (parametrizada) y lo vuelve a usar más tarde, en otra consulta, construida esta vez por concatenación y sin volver a tratarlo como dato. La inyección "duerme" almacenada hasta ese segundo uso. Es peligrosa precisamente porque el dato parece ya "de confianza" por venir de la propia base de datos. La lección: el origen de un dato no lo hace seguro; lo que importa es cómo se usa en cada consulta.

9. Evadir filtros y WAF

Bloquear una palabra (por ejemplo OR) no es una defensa: existen equivalentes que no la necesitan. Repertorio habitual de evasión:

Defensa ingenuaEvasión
Bloquea OR/UNION en minúsculasCambiar mayúsculas: oR, UnIoN (SQL no distingue mayúsculas)
Bloquea la palabra exactaComentarios intercalados: UN/**/ION, OR → ||
Filtra espaciosSustituirlos por /**/, tabuladores o paréntesis
Filtra comillasCodificación (hex, CHAR()) o contexto numérico sin comillas

Un WAF sube el listón, pero es defensa en profundidad, no la solución: se puede evadir. La única barrera real es no construir SQL con texto del usuario.

10. La defensa que sí funciona: consultas parametrizadas

Una consulta parametrizada (o prepared statement) separa en dos pasos lo que la construcción por concatenación mezcla: primero se envía al motor la plantilla de la consulta con marcadores (?), que el motor parsea y fija; después se envían los datos por un canal aparte. Como la estructura ya está decidida antes de que llegue el dato, nada de lo que el usuario escriba puede cambiarla: su entrada solo puede ser un valor, nunca código. Por eso elimina la inyección de raíz, mientras que "escapar" o "validar" la entrada son parches que siempre tienen huecos.

kali@lab
$ cursor.execute("SELECT * FROM usuarios WHERE usuario = ? AND contrasena = ?", (usuario, contrasena))

Complementos, nunca sustitutos: cuenta de base de datos con mínimo privilegio, no mostrar errores al usuario, y un WAF como capa extra.

Para profundizar
🧰 Herramientas relacionadas (Ghost Academy)

Guías y laboratorios ya publicados en Ghost Academy sobre las herramientas de este módulo:

SQLi Lab · Burp Suite (guía)

Laboratorio: Vega Records

⬇ Descargar laboratorio (ZIP)

Descomprime, lee el LEEME.md y lanza startlab.sh. Encontrarás 8 flags GO{...}: autenticación, UNION, error verboso, ciego booleano, ciego por tiempo, ORDER BY, segundo orden y bypass de filtro. Verifícalas en http://localhost:8484/verificador.

¿Atascado o ya terminado? La solución paso a paso revela todas las flags. Úsala cuando lo hayas intentado.

Autoevaluación

  1. En términos de "contexto de dato" y "contexto de código", ¿qué hace exactamente la comilla en un payload de inyección?
  2. ¿En qué se diferencia el payload en un parámetro numérico (id=42) del de uno de cadena (usuario='...')?
  3. Tienes un login inyectable pero no conoces ningún nombre de usuario. ¿Qué payload usarías y por qué funciona?
  4. Antes de un UNION SELECT, ¿qué dos cosas tienes que averiguar y cómo?
  5. Explica, con la búsqueda binaria, por qué extraer un carácter a ciegas cuesta ~7 preguntas y no 128.
  6. ¿Cuándo recurrirías a la inyección basada en tiempo en lugar de a la booleana?
  7. ¿Por qué la inyección de segundo orden puede pasar desapercibida a quien revisa solo el formulario de entrada?
  8. ¿Por qué una consulta parametrizada elimina el problema de raíz y "escapar la entrada" no?