Skip to content
This page has been auto-translated and may contain errors.View in English

Inyección SQL

Una validación de login es una pregunta para la base de datos. ¿Existe una fila donde el nombre de usuario y la contraseña coinciden con lo que se escribió?

Vulnerable
js
const query = `
  SELECT * FROM users
  WHERE username = '${username}' AND password = '${password}'
`

Si regresa una fila, las credenciales eran correctas. Si no regresa ninguna, no lo eran.

Eso se lee como una pregunta. Para la base de datos es una oración, y quien la visita logró escribir parte de ella.

Una consulta construida pegando strings deja que quien la envía decida qué significa la consulta.

El bug

SQL es un lenguaje, y esta consulta es un programa escrito de cero en cada solicitud. La mayor parte viene de ti. Dos piezas vienen de quien esté frente al teclado.

La base de datos nunca ve esa costura. Cuando la consulta llega, es un solo bloque continuo de texto, y la base de datos no tiene forma de saber cuáles caracteres escribiste tú y cuáles un desconocido. Analiza todo el texto y hace lo que dice.

Eso es la inyección SQL: datos que terminan leyéndose como parte de la consulta en vez de como un valor dentro de ella. Las consecuencias dependen de todo lo que SQL puede expresar, que es bastante:

  • Leer filas que quien hace la solicitud nunca debería ver.
  • Cambiar o borrar datos.
  • Ejecutar comandos administrativos contra el sistema de base de datos.
  • En algunas configuraciones, llegar hasta el sistema operativo subyacente.
JunoEl bug El template literal es lo que hace difícil detectar esto. Parece que estás rellenando un espacio en blanco, como harías en una oración.

Lo que en realidad estás haciendo es entregarle a la base de datos una oración terminada, con la esperanza de que las palabras que puso alguien más sean solo palabras.

JunoEl bug En una revisión de código, la señal de alerta es cualquier consulta armada con concatenación de strings o un template literal que lleve una variable. Busca comillas invertidas (backticks) y + cerca de SELECT, INSERT, UPDATE y DELETE.

Estar autenticado tampoco ayuda. Un usuario autenticado que envía un valor manipulado es el mismo bug, y muchas veces tiene tablas más interesantes a las que llegar.

JunoEl bug Esta clase de bug va más allá de SQL, y reconocer el patrón es lo que realmente se puede aplicar en otros contextos. Cada vez que un valor pasa de ser un dato a entrar en un lenguaje con su propio analizador, ese analizador decide qué significa el valor: un comando de shell, un documento XML, una plantilla, una ruta de archivo.

La inyección de segundo orden es la variante que sobrevive a una corrección parcial. Un valor se guarda de forma segura mediante un insert parametrizado, y luego se lee más adelante y se concatena en otra consulta, por código que asume que cualquier dato ya presente en la base de datos es confiable.

El payload queda inerte en una fila durante meses y se activa desde un job de reportes. Parametriza todas las consultas, no solo las que reciben datos directamente del usuario.

El ataque

Deja la contraseña en paz. Escribe esto como nombre de usuario:

text
' OR 1=1 --

Sustituido en la plantilla, la base de datos recibe:

sql
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''

Tres caracteres hicieron todo el trabajo:

  1. La ' de apertura cierra el string del username antes de tiempo, así que todo lo que viene después se lee como sintaxis de la consulta y no como un nombre.
  2. OR 1=1 es una condición siempre verdadera, así que toda la cláusula WHERE se cumple para cada fila.
  3. -- inicia un comentario de SQL, así que la verificación de contraseña que viene después queda como texto que la base de datos ignora.

Cualquier usuario aparece como resultado. La aplicación toma la primera fila como prueba de un login exitoso y deja entrar al atacante como si fuera otra persona.

Prueba esto solo contra tu propia base de datos

Estos payloads modifican y destruyen datos. Eso los vuelve peligrosos en cualquier lugar con registros reales, incluida una copia de staging de producción. Usa una base de datos local que puedas borrar y reconstruir, o un sistema para el que tengas permiso escrito de hacer pruebas.

JunoEl ataque Lee el payload como tres movimientos en vez de un solo string, y deja de parecer magia.

Cierra la comilla para pasar de escribir texto a escribir consulta. Agrega una condición que siempre sea verdadera. Comenta todo lo que no quieras que se procese.

JunoEl ataque Fíjate en que el ataque no necesitó una contraseña ni tuvo que adivinar ninguna. Eliminó por completo la verificación de contraseña de la consulta.

Por eso decir "nuestro login es seguro porque las contraseñas están hasheadas" no resuelve el problema aquí. El hash protege el valor guardado si la tabla se filtra. No hace nada cuando la comparación en sí nunca llega a ejecutarse.

JunoEl ataque Vale la pena recordar un detalle específico de dialecto, porque ha llevado a conclusiones erróneas en pruebas reales. En MySQL, -- solo inicia un comentario cuando va seguido de un espacio en blanco.

Por eso --' ahí no es un comentario, y un payload copiado de un ejemplo de PostgreSQL puede fallar contra MySQL aunque el bug subyacente esté completamente presente. # es el otro marcador de comentario de MySQL.

Esta es la lección real: un payload que no hace nada te dice casi nada. Descarta una cadena en un dialecto en particular, no la vulnerabilidad.

La otra mitad de la respuesta tiene que ver con los permisos con los que corre la consulta. Una cuenta de aplicación con privilegios de DROP, o con acceso de lectura a tablas que la funcionalidad nunca toca, convierte una sola inyección en un incidente mucho más grande.

El principio de mínimo privilegio en el usuario de la base de datos es lo que limita el daño cuando la parametrización se pasa por alto en algún lugar.

La solución

Deja de construir la oración. Describe su estructura una sola vez, y luego entrega los valores por separado:

Fixed
js
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'

const [rows] = await db.execute(query, [username, password])

Las marcas ? son placeholders. La sintaxis exacta varía según la base de datos y la librería, $1 y $2 en algunas, parámetros con nombre en otras, pero la idea es la misma en todos los casos: el texto de la consulta queda fijo antes de que llegue cualquier valor del usuario.

Esto se llama sentencia preparada con enlace de variables, o más comúnmente, consulta parametrizada.

JunoLa solución La diferencia está en cuándo la base de datos aprende la estructura. En la versión con strings pegados, estructura y datos llegan juntos como un solo bloque, y la base de datos deduce la forma a partir de lo que sea que recibe.

Aquí la forma queda definida primero. Los valores llegan después, como valores, y nada en ellos puede cambiar una decisión que ya se tomó.

JunoLa solución Esta es una de esas raras correcciones de seguridad que además dan un código más agradable de escribir. Sin malabares con comillas, sin funciones de escape que recordar, y el driver se encarga de la conversión de tipos por ti.

Cualquier consulta concatenada que quede debe tratarse como algo que necesita una justificación por escrito. Un ORM, el mapeador objeto-relacional que genera SQL a partir de tu código, parametriza automáticamente en sus métodos de consulta normales.

Su vía de escape de SQL en crudo no lo hace, así que ahí es donde hay que mirar primero.

JunoLa solución Los placeholders enlazan valores, nunca identificadores. Los nombres de tabla, los nombres de columna y la dirección en ORDER BY no se pueden parametrizar, porque forman parte de la estructura que se le está indicando a la base de datos de antemano.

Por eso un endpoint de ordenamiento que toma el nombre de una columna desde un query string sigue siendo inyectable aunque todos los valores estén parametrizados. La solución ahí es una lista blanca: mapear la entrada del usuario a un identificador conocido y válido, y rechazar cualquier cosa que no coincida.

Construye esa lista blanca con un Map, Object.hasOwn() u Object.create(null). Un objeto literal común es permeable, porque ALLOWED[userInput] devuelve Object.prototype.toString para la entrada toString, lo cual es verdadero (truthy) y pasa sin problema una validación ingenua.

Por qué funciona la solución

Aquí nadie inspecciona el dato de entrada ni le quita nada. ' OR 1=1 -- sigue llegando exactamente como se escribió.

Lo que cambió es dónde termina ese valor. La base de datos ya sabe que está buscando un username y un password, así que el payload se compara como un username, carácter por carácter, contra una columna de nombres.

Ninguna cuenta se llama ' OR 1=1 --. No regresa ninguna fila, el login falla, y esa es la respuesta correcta.

Esa es la defensa a nivel de la base de datos, y se suma a las demás que esta sección ha ido construyendo:

CapaQué decide
Validación en el navegadorSi el formulario se envía, solo para usuarios honestos
Validación de esquema en el servidorSi la solicitud tiene la forma correcta para siquiera aceptarla
Salida seguraSi un valor guardado puede convertirse en código dentro de una página
Consultas parametrizadasSi un valor puede convertirse en parte de una consulta
JunoPor qué funciona la solución El payload no está desactivado. Ahora se lee como un nombre, y es un nombre muy poco común que nadie tiene.

Vale la pena quedarse con esta idea: las correcciones más seguras suelen cambiar cómo se trata un valor, en vez de intentar averiguar si se ve peligroso.

JunoPor qué funciona la solución Por esto escapar comillas a mano es el instinto equivocado incluso cuando parece funcionar. Terminas manteniendo una suposición sobre las reglas de comillas de una base de datos en particular.

Esa suposición se rompe con un dialecto distinto, una codificación de caracteres distinta, o un campo numérico donde ni siquiera había comillas de por medio.

La parametrización elimina la pregunta en vez de responderla.

JunoPor qué funciona la solución Vale la pena ser precisos sobre qué cubre la parametrización y qué no, porque "usamos un ORM" suele tratarse como una respuesta definitiva.

Cubre valores dentro de una consulta cuya estructura ya fijaste. No cubre identificadores, vías de escape de SQL en crudo, fragmentos de WHERE armados dinámicamente, ni un stored procedure que concatena internamente. Cada uno de esos casos es un punto donde la garantía deja de aplicar.

La razón por la que la validación de esquema sigue teniendo un lugar por encima de esto es que responden preguntas distintas. La parametrización hace que un valor sea seguro para la base de datos. La validación decide si de verdad querías un username de 4000 caracteres desde un principio, que es algo sobre lo que la capa de base de datos no tiene ninguna opinión.

Ponlo a prueba

Un formulario de registro inserta un nombre y un correo:

Vulnerable
js
const query = `
  INSERT INTO users (name, email)
  VALUES ('${name}', '${email}')
`

Ponte en el lugar del atacante. Diseña un valor para el campo de correo que borre por completo la tabla users.

Hay tres cosas que resolver: cómo dejar de estar dentro del valor entre comillas, cómo dejar de estar dentro de los paréntesis, y cómo terminar una sentencia para que pueda empezar una segunda.

Compara tus respuestas

El valor del correo es:

text
'); DROP TABLE users; --

Que produce:

sql
INSERT INTO users (name, email)
VALUES ('Daniela', ''); DROP TABLE users; --')

Cuatro movimientos, que corresponden a las tres preguntas más un ajuste final:

  • ' cierra el string dentro del que estaba el correo.
  • ) cierra el paréntesis de VALUES, completando el INSERT como una sentencia válida.
  • ; termina esa sentencia, así que lo que sigue se lee como una nueva.
  • -- comenta el ') que quedó suelto al final, que de otro modo sería un error de sintaxis y haría que todo el bloque fuera rechazado.

El último movimiento es el que la mayoría pasa por alto. Sin él la sentencia queda malformada, la base de datos rechaza todo el bloque, y el ataque falla por razones que no tienen nada que ver con tus defensas.

Vale la pena saber esto: muchos drivers rechazan por defecto múltiples sentencias en una sola llamada, así que este payload exacto no siempre funciona. Eso es una configuración que te protege, no una corrección real. El mismo hueco de seguridad sigue permitiendo leer cualquier dato que un atacante quiera mediante un UNION SELECT, que ni siquiera necesita una segunda sentencia.

Parametriza la consulta y todo el string se convierte simplemente en el correo poco común de alguien, guardado tal como se escribió.

Hacia dónde va esto

Tres capítulos, tres bugs, una sola causa. Nada verificó lo que llegaba antes de que la aplicación actuara sobre ello, así que un valor elegido por un desconocido terminó decidiendo qué hacía el código.

Cada corrección hasta ahora ha estado en el punto de uso: la propiedad correcta para renderizar, un límite de tamaño, un placeholder en una consulta. Son necesarias, y también llegan tarde: hay que repetirlas en cada destino, y es fácil olvidarlas una sola vez.

Zod fundamentals empieza la otra mitad del trabajo: describir la forma que esperas una sola vez, en el límite de entrada, y rechazar todo lo que no coincida.