Inyección SQL
Cómo la concatenación de entrada en una consulta permite inyectar SQL, su impacto, y las defensas en profundidad que empiezan por las consultas parametrizadas.
La inyección SQLInyección SQLVulnerabilidad que permite alterar una consulta SQL insertando datos de entrada que la aplicación concatena como si fueran código. lleva más de dos décadas entre los primeros puestos del OWASP Top 10 (categoría A03, Injection) y sigue viva porque su causa es un error fácil de cometer: mezclar código y datos. Entender cómo funciona es la mejor forma de reconocerla en una revisión y de aplicar la defensa correcta —que, por suerte, es mecánica y casi infalible. Todo lo que sigue es para defender y detectar; pruébalo solo contra tu propio laboratorio autorizado.
Cómo funciona: concatenar es el fallo#
Una consulta SQL tiene dos partes que el motor distingue: la estructura (SELECT … WHERE email =) y los datos (el email concreto). El problema aparece cuando la aplicación construye la consulta pegando la entrada del usuario como texto:
// La entrada del usuario se concatena directamente en la cadena SQLconst q = `SELECT * FROM pacientes WHERE email = '${email}'`;db.query(q);Si email vale ana@correo.es, la consulta es correcta. Pero si el usuario envía ' OR 1=1--, la cadena resultante es:
SELECT * FROM pacientes WHERE email = '' OR 1=1--'El ' cierra el literal antes de tiempo, OR 1=1 hace la condición siempre cierta y -- comenta el resto. La base de datos no ve un ataque: ve una consulta válida que pide todas las filas. El motor hizo exactamente lo que le mandaron; el fallo fue de la aplicación, al dejar que la entrada cambiara la estructura.
Paso 1 de 5La entrada
Un usuario envía al login un valor que no es un email, sino SQL: ' OR 1=1--. Para la aplicación es solo texto de un formulario.
Variantes (entenderlas para detectarlas)#
El ' OR 1=1-- es la demostración de manual, pero un atacante real adapta la técnica a lo que la aplicación le deja ver. Conocer las variantes te ayuda a reconocerlas en los logs y a diseñar pruebas en tu laboratorio.
Cuando la app devuelve resultados, UNION SELECT permite añadir filas de otras tablas a la respuesta, incluso de information_schema para mapear el esquema:
' UNION SELECT usuario, hash_password FROM credenciales--Requiere cuadrar el número y tipo de columnas, algo que el atacante descubre probando.
Si la app no muestra datos pero se comporta distinto según la consulta sea verdadera o falsa (una página que carga o no), se extrae información bit a bit:
' AND SUBSTRING((SELECT password FROM admin LIMIT 1),1,1)='a'--Lenta pero automatizable: la respuesta “normal” frente a “error” filtra cada carácter.
La variante más sigilosa: cuando no hay ninguna diferencia visible, se infiere la respuesta midiendo el tiempo. Si la condición es cierta, la BD duerme:
'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END--Cinco segundos de más delatan un true. Es la señal que un WAF o los logs de latencia pueden ayudar a detectar.
Impacto#
La inyección SQL raramente se queda en “leer una tabla”. Según los permisos del usuario de base de datos y el motor, puede escalar mucho:
- Confidencialidad: volcar toda la PII de pacientes, hashes de contraseñas, tokens.
- Integridad:
UPDATE/DELETEpara alterar o borrar citas e historiales, o crear cuentas de administrador. - Autenticación: saltarse el login con
' OR 1=1--en el campo usuario. - Pivote al sistema: en algunos motores, lectura/escritura de ficheros o ejecución de comandos (
xp_cmdshell,COPY … PROGRAM), que convierten un bug web en acceso al servidor.
Para Citas, una sola inyección en el buscador de pacientes equivale a una fuga de PII con obligación de notificación (RGPD, 72 h). Por eso la defensa no es opcional.
Defensas en profundidad#
Una buena noticia poco habitual en seguridad: la inyección SQL tiene una defensa casi perfecta y barata. El resto son capas que reducen el daño si la primera falla.
1. Consultas parametrizadas (la correcta)#
Separa estructura y datos: la consulta viaja con marcadores ($1, ?) y la entrada se envía aparte, como parámetro. El driver nunca la interpreta como SQL, así que ' OR 1=1-- se busca como un email literal y no coincide con nada.
Vulnerable
Seguro
// Concatenación: la entrada puede cambiar la estructura de la consultaconst q = `SELECT * FROM pacientes WHERE email = '${email}'`;const { rows } = await db.query(q);// Parametrizada: la entrada viaja como dato, nunca como códigoconst q = 'SELECT * FROM pacientes WHERE email = $1';const { rows } = await db.query(q, [email]);2. ORM y query builders, con cuidado#
Un ORM (Prisma, Sequelize, TypeORM, SQLAlchemy) parametriza por defecto, lo que elimina la mayoría de inyecciones sin esfuerzo. Pero no es magia: en cuanto usas raw queries o interpolas en un fragmento, vuelves al punto de partida. El ORM ayuda si no lo saboteas.
3. Validación de entrada (capa, no defensa principal)#
Validar que un id es numérico o que un email tiene formato reduce la superficie, pero no sustituye a la parametrización: hay inyecciones que pasan cualquier allow-list razonable. Valida por robustez y por higiene, no como barrera única.
4. Mínimo privilegio en el usuario de BD#
Si la API se conecta con un usuario que solo tiene SELECT/INSERT/UPDATE sobre las tablas que necesita —y no DROP, ni acceso a ficheros, ni a otras bases—, una inyección hace mucho menos daño. Es mínimo privilegio aplicado a la capa de datos: no evita el bug, pero acota el radio de impacto.
5. WAF como capa externa#
AWS WAF (delante de CloudFront o el ALB) tiene reglas gestionadas contra patrones de SQLi. Frena escaneos automáticos y oportunistas, pero se evade con ofuscación: trátalo como detección y reducción de ruido, nunca como el control que te protege. La defensa real vive en el código.
Tu turno
Practica SQLi en un laboratorio autorizado
La mejor forma de interiorizar la inyección SQL es explotarla tú en un entorno hecho para ello. La PortSwigger Web Security Academy tiene laboratorios gratuitos y guiados (SQL injection UNION, blind, time-based) que son tu propio laboratorio autorizado: nunca pruebes contra sistemas que no sean tuyos.
- Resuelve un lab de SQLi boolean-based blind y anota cómo sabe la herramienta que una condición es cierta.
- Luego reproduce la defensa: coge un endpoint vulnerable de tu laboratorio y reescríbelo con consultas parametrizadas. Comprueba que
' OR 1=1--ya no hace nada. - Revisa un repositorio real que conozcas y busca concatenaciones de SQL (
`...${ }...`junto aquery,execute). ¿Cuántas encuentras?
Reto
¿Por qué la validación de entrada no basta como defensa contra SQLi?
▸Mostrar solución
Porque hay entradas legítimas que contienen caracteres “peligrosos” (el apellido O’Brien lleva una comilla) y porque las variantes de inyección no siempre necesitan comillas (contextos numéricos, nombres de columna). Una allow-list estricta rompe datos válidos y aun así deja huecos. La parametrización funciona sin adivinar qué entrada es peligrosa: trata toda la entrada como dato, siempre.
Reto
Usas un ORM. ¿Estás a salvo de la inyección SQL?
▸Mostrar solución
Casi, no del todo. El ORM parametriza las consultas que generas con su API, pero muchos permiten
raw queries y fragmentos SQL donde puedes volver a concatenar entrada. La inyección reaparece en
cuanto interpolas una variable dentro de un queryRaw o un WHERE construido a mano. El ORM
reduce el riesgo; no lo elimina si lo esquivas.
En resumen#
- La inyección SQL nace de concatenar entrada en una consulta: la entrada cambia la estructura y el motor la ejecuta.
- Variantes:
' OR 1=1--,UNION, blind (boolean) y time-based; conócelas para detectarlas en los logs. - El impacto va de volcar PII a tomar el servidor, según los permisos del usuario de BD.
- La defensa correcta son las consultas parametrizadas (prepared statements); el ORM ayuda si no usas raw.
- Alrededor, en capas: validación, mínimo privilegio del usuario de BD y WAF como detección. La defensa real está en el código.