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.
- Entender, a nivel de parser, por qué una comilla rompe una consulta y qué significa "salir del contexto de dato".
- Detectar de forma sistemática si un parámetro es inyectable, distinguiendo contexto numérico y de cadena.
- Saltarse una autenticación con tautologías y comentarios, entendiendo cada carácter del payload.
- Identificar el motor de base de datos y reconstruir su esquema (error-based e
information_schema). - Extraer datos con
UNION SELECT, resolviendo número y tipo de columnas. - Reconstruir datos a ciegas, carácter a carácter, con inyección booleana (y búsqueda binaria) y basada en tiempo.
- Comprender la inyección de segundo orden y por qué las listas negras de palabras nunca bastan.
- Explicar por qué las consultas parametrizadas eliminan el problema de raíz.
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:
$ 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.
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:
| Prueba | Qué 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'='2 | Si 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:
- Contexto de cadena: el valor va entre comillas (
usuario='...'). Necesitas una comilla para salir. - Contexto numérico: el valor va sin comillas (
WHERE id=42). Aquí no hace falta comilla:42 OR 1=1ya inyecta.
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' -- :
$ 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:
- Basada en errores: si la aplicación muestra los errores del motor, cada mensaje revela el software (MySQL, PostgreSQL, MSSQL, SQLite tienen mensajes distintos), y a veces la propia consulta y nombres de columna. Provocar un error con una comilla suelta es reconocimiento legítimo.
- Catálogo del sistema: casi todos los motores exponen un catálogo consultable. En MySQL/PostgreSQL/MSSQL es
information_schema; en SQLite essqlite_master. Desde una inyección se puede listar todo el esquema:
$ ' UNION SELECT table_name, column_name FROM information_schema.columns -- | Necesitas... | MySQL / PostgreSQL / MSSQL | SQLite |
|---|---|---|
| Versión | version() / @@version | sqlite_version() |
| Listar tablas | information_schema.tables | sqlite_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:
- 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.) - 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.
$ ' 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:
$ ' 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:
$ ' 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 ingenua | Evasión |
|---|---|
Bloquea OR/UNION en minúsculas | Cambiar mayúsculas: oR, UnIoN (SQL no distingue mayúsculas) |
| Bloquea la palabra exacta | Comentarios intercalados: UN/**/ION, OR → || |
| Filtra espacios | Sustituirlos por /**/, tabuladores o paréntesis |
| Filtra comillas | Codificació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.
$ 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.
- OWASP: SQL Injection Prevention Cheat Sheet y el Top 10 (categoría "Injection").
- PortSwigger Web Security Academy: laboratorios gratuitos de SQLi, de básico a ciego por tiempo.
- Práctica: reproduce cada técnica de este capítulo en el laboratorio Vega Records y luego automatízala con
sqlmappara comparar tu extracción manual con la de la herramienta.
Guías y laboratorios ya publicados en Ghost Academy sobre las herramientas de este módulo:
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
- En términos de "contexto de dato" y "contexto de código", ¿qué hace exactamente la comilla en un payload de inyección?
- ¿En qué se diferencia el payload en un parámetro numérico (
id=42) del de uno de cadena (usuario='...')? - Tienes un login inyectable pero no conoces ningún nombre de usuario. ¿Qué payload usarías y por qué funciona?
- Antes de un
UNION SELECT, ¿qué dos cosas tienes que averiguar y cómo? - Explica, con la búsqueda binaria, por qué extraer un carácter a ciegas cuesta ~7 preguntas y no 128.
- ¿Cuándo recurrirías a la inyección basada en tiempo en lugar de a la booleana?
- ¿Por qué la inyección de segundo orden puede pasar desapercibida a quien revisa solo el formulario de entrada?
- ¿Por qué una consulta parametrizada elimina el problema de raíz y "escapar la entrada" no?