Saltar al contenido
Nuevo

DeepSeek V4.1 Flash gratis por 7 días: 10M tokens al día tras cualquier recarga. Hasta el 30/11.

Cómo funciona

SQL Injection explicada con OWASP Juice Shop

Cómo la SQL Injection cambia el sentido de una consulta, por qué las consultas parametrizadas la corrigen y un laboratorio con Juice Shop y sqlite3.

Intermedio11 min de lecturaPor NotHoly

La SQL Injection ocurre cuando una aplicación construye una consulta a la base de datos uniendo su propio texto SQL con datos que llegaron desde el usuario. La base recibe una sola cadena y no puede saber qué caracteres escribió el desarrollador y cuáles escribió el usuario. Cuando el texto del usuario llega al analizador como SQL, puede cambiar lo que la consulta significa. La consulta puede devolver otras filas, saltarse una comprobación, modificar datos o, en los peores casos, alcanzar cosas que la aplicación nunca debió exponer.

Este artículo explica el mecanismo, muestra la corrección que ataca la causa y tiene dos partes prácticas. La primera es un desafío de login en OWASP Juice Shop, que ejecutas en tu propia máquina. La segunda es un script corto en Python con sqlite3 que reproduce el mismo error y la corrección. Si ya leíste OWASP Top 10 para desarrolladores, ya tienes la categoría en mente. Aquí tomamos un elemento de esa lista y bajamos hasta el texto de la consulta.

Qué es la SQL Injection

La definición CWE de esta debilidad, CWE-89, la describe con claridad: el software construye parte de un comando SQL a partir de una entrada y no neutraliza la sintaxis SQL que viene dentro de esa entrada. Entonces el dato se interpreta como comando.

Tres palabras explican la definición: construye, entrada e interpreta. El error no está en que la aplicación hable con una base de datos. El error está en que la aplicación arma por su cuenta el texto de la consulta, y algunas de las piezas vienen de afuera.

Por qué importa

Una consulta de login es el ejemplo más pequeño y útil. Comprueba un nombre y una contraseña. Si un atacante puede cambiar la consulta, puede cambiar quién queda autenticado. El mismo defecto en un buscador puede devolver filas que el usuario nunca debería ver. En un panel de administración puede cambiar precios, roles o datos almacenados. Muchos de estos errores empiezan con un solo carácter de comilla que llega a la base sin escapar, y así suele confirmarlo por primera vez quien prueba.

La inyección tampoco necesita un payload sofisticado. Los desarrolladores la encuentran a menudo con una entrada que hace fallar la aplicación o devuelve un error extraño. Un error que menciona sintaxis SQL es una señal fuerte de que la entrada llegó a la consulta.

Cómo cambia el significado de la consulta

Empieza con una plantilla que un desarrollador podría escribir:

sql
SELECT * FROM users WHERE name = '<name>' AND pw = '<pw>'

Imagina que el campo de nombre recibe admin' -- y el campo de contraseña recibe wrong. Después de que la aplicación pone esas cadenas en la plantilla, la base analiza esto:

sql
SELECT * FROM users WHERE name = 'admin' --' AND pw = 'wrong'

Léela paso a paso. La primera comilla después de admin cierra el literal de cadena que abrió el desarrollador. Luego, -- inicia un comentario en SQLite, y en la mayoría de los dialectos SQL. Todo lo que viene después se ignora, incluida la condición de la contraseña. La base recibe una consulta válida que pide la fila cuyo nombre es admin, y nada más.

Pasan cuatro cosas, en orden:

  1. La aplicación copia los caracteres del usuario en el texto de la consulta.
  2. El analizador de la base lee toda la cadena como SQL. No distingue qué caracteres vienen del desarrollador y cuáles del usuario.
  3. Una comilla del usuario termina el literal antes de tiempo. El texto que sigue se analiza como palabras clave, operadores o comentarios SQL.
  4. La condición que escribió el desarrollador deja de ser la que ejecuta la base.

La f-string, la concatenación, el formato con %, .format() y las plantillas de cadenas en JavaScript tienen el mismo defecto. El lenguaje no importa. Lo que importa es el método con el que unes el texto y el SQL.

La corrección de raíz: consultas parametrizadas

La parametrización separa el texto SQL de los valores. Envías la consulta con marcadores y los valores llegan a la base por el driver como datos. La base nunca los interpreta como SQL, así que una comilla dentro de un valor no tiene ningún significado especial.

La documentación de sqlite3 de Python recomienda usar marcadores en lugar de formatear cadenas al vincular valores de Python a sentencias SQL, precisamente para evitar la SQL Injection. La hoja de referencia de prevención de SQL Injection de OWASP señala las consultas parametrizadas como la defensa principal por la misma razón: la estructura de la consulta se define por separado de los datos.

La validación sigue siendo útil, pero como segunda capa. Lo que corrige la causa es la vinculación de parámetros. Si validas un valor y luego lo concatenas en la consulta, el error sigue ahí.

Manos a la obra: resuelve el desafío Login Admin

Juice Shop es una aplicación web vulnerable a propósito, que OWASP mantiene para entrenamiento. Ejecútala solo en tu máquina y mantén el puerto ligado a la dirección de loopback, para que ningún otro equipo pueda alcanzarlo.

Levanta el laboratorio en localhost

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

El prefijo 127.0.0.1: publica el puerto solo en la interfaz de loopback. Si el puerto 3000 ya está en uso, Docker falla con un error como port is already allocated. En la máquina de prueba usada para este artículo, otro contenedor local ya ocupaba el puerto 3000 y el comando falló por eso. Elige un puerto libre, por ejemplo 127.0.0.1:3001:3000, y usa ese puerto en los pasos siguientes.

Confirma que la aplicación responde y revisa la versión:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

En la máquina de prueba, la respuesta fue:

text
{"version":"20.3.0"}

Encuentra el punto de inyección

La guía oficial de Juice Shop, Pwning OWASP Juice Shop, no muestra el payload de este desafío. Da pistas: la descripción del desafío indica qué formulario atacar, un ataque dirigido funciona bien si ya conoces el correo del administrador, y un patrón de inyección común puede funcionar aunque no conozcas ese dato. Sigue esas pistas. Empieza por el formulario de login y pon una comilla en el campo de correo. Una contraseña incorrecta normal debería dar un error. Una consulta rota suele dar otra respuesta.

En la máquina de prueba, una contraseña incorrecta para el correo del administrador, sin ninguna inyección, devolvió:

text
Invalid email or password.

con estado HTTP 401. Esa es la línea base. Después, la misma petición con una comilla y un comentario en el campo de correo:

bash
curl -s -X POST http://127.0.0.1:3000/rest/user/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]'\''--","password":"wrong"}'

La respuesta contiene un objeto authentication con un campo token. La contraseña era incorrecta, pero el servidor emitió un token de sesión. Al decodificar la parte central del JSON Web Token, que va en base64url, aparece "role":"admin". La respuesta llegó con HTTP 200 en la máquina de prueba.

Revisa el estado del desafío con la API del laboratorio:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=Login%20Admin"

En la máquina de prueba, el desafío apareció como resuelto, con dificultad 2. El marcador es la fuente de verdad del desafío. Tu resultado debe coincidir con esta misma consulta.

La guía de acompañamiento y el código fuente de Juice Shop son los lugares para confirmar la consulta exacta detrás de este formulario. Léelos después de tener el resultado, para entender el mecanismo en lugar de solo copiar un payload.

Detén el laboratorio

bash
docker ps
docker stop <nombre-del-contenedor>

Usa el nombre o el ID del contenedor que muestre docker ps. Cerrar la pestaña del navegador no detiene el contenedor.

Reproduce el error y la corrección en Python

Guarda esto como sqli_demo.py. Solo usa la biblioteca estándar. El script crea una tabla users pequeña en memoria y expone dos funciones de login. Una construye la consulta con una f-string. La otra usa un marcador.

python
import sqlite3

con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (name TEXT, pw TEXT)")
cur.execute("INSERT INTO users VALUES ('alice', 'correct-horse'), ('admin', 'S3cret!')")

def login_unsafe(user, pw):
    query = f"SELECT * FROM users WHERE name='{user}' AND pw='{pw}'"
    print("  built:", query)
    return cur.execute(query).fetchall()

def login_safe(user, pw):
    return cur.execute('SELECT * FROM users WHERE name=? AND pw=?', (user, pw)).fetchall()

if __name__ == "__main__":
    print("same valid login, unsafe:", login_unsafe("admin", "S3cret!"))
    print("same valid login, safe:  ", login_safe("admin", "S3cret!"))
    print("crafted input, unsafe:")
    print("  result:", login_unsafe("admin' --", "wrong"))
    print("crafted input, safe:")
    print("  result:", login_safe("admin' --", "wrong"))

Ejecútalo. En Windows, con Python instalado como python, el comando es python sqli_demo.py. En la máquina de prueba, python3 no estaba en el PATH, así que usa python. La salida observada en la máquina de prueba con Python 3.12.10:

text
same valid login, unsafe: [('admin', 'S3cret!')]
same valid login, safe:   [('admin', 'S3cret!')]
crafted input, unsafe:
  built: SELECT * FROM users WHERE name='admin' --' AND pw='wrong'
  result: [('admin', 'S3cret!')]
crafted input, safe:
  result: []

Compara las dos mitades. Para un login normal, ambas funciones devuelven la misma fila, así que la versión insegura parece correcta. Por eso este error sobrevive a las revisiones de código. Con entrada maliciosa, la función insegura imprime la consulta que recibió la base. El comentario elimina la comprobación de la contraseña, y la fila del administrador vuelve aunque la contraseña sea incorrecta. La función segura trata admin' -- como un nombre literal. Ningún usuario tiene ese nombre, así que devuelve una lista vacía.

El script guarda contraseñas en texto plano porque es una demostración. Los sistemas reales guardan un hash de contraseña con sal y comparan hashes.

Dónde los ORM y los helpers siguen fallando

Un ORM no hace imposible el error. Te da un valor seguro por defecto para las consultas normales y un conjunto de salidas de emergencia para todo lo demás. Estos son los puntos donde el error suele volver:

  • Métodos de SQL crudo. Llamadas como raw, text, extra o un execute directo aceptan una cadena. Si esa cadena se arma con una f-string o con concatenación, volviste al patrón inseguro.
  • Nombres de tabla y de columna. Los marcadores vinculan valores. No vinculan identificadores. Una columna de ORDER BY o un nombre de tabla que elige el usuario tiene que pasar por una lista de permitidos que traduce la elección a nombres que tú mismo escribiste. OWASP recomienda el mismo enfoque para opciones estructurales fijas, como la dirección del orden.
  • Listas IN dinámicas. La cantidad de marcadores cambia con la entrada. Genera el número correcto de ? en tu propio código y pasa los valores como tupla. No unas los valores del usuario en el texto.
  • Funciones auxiliares de filtro. Una función que recibe un filtro en forma de cadena y lo agrega a una cláusula WHERE es un punto de concatenación, aunque el resto de la aplicación use bien el ORM.

Errores comunes

Bloquear palabras. Un filtro que rechaza OR, SELECT, comillas o -- se esquiva fácilmente con cambios de mayúsculas, codificaciones y otros estilos de comentario. La hoja de referencia de OWASP es clara: la validación no reemplaza la vinculación de parámetros. Usa la vinculación primero. La validación sirve para las opciones estructurales fijas de arriba, no como defensa principal.

Escapar comillas a mano. Cada driver tiene sus reglas, y basta un caso olvidado. Deja que el driver haga ese trabajo.

Confiar solo en el mínimo privilegio. Una cuenta de base con menos permisos limita lo que un atacante puede hacer después de la inyección. No impide la inyección. OWASP trata el mínimo privilegio como un control complementario y dice que las cuentas de la aplicación nunca deben tener permisos de DBA o administrador. Haz las dos cosas: parametriza y da a la aplicación el menor conjunto de permisos que necesita.

Parametrizar los valores y olvidar el resto. Un equipo convierte la cláusula WHERE a marcadores, pero concatena la columna de orden sin pensarlo. Revisa cada lugar donde entra texto en una consulta, no solo el más obvio.

Probar solo con entrada normal. El error aparece solo cuando la entrada contiene sintaxis SQL. Agrega una prueba que envíe el valor malicioso y espere un resultado vacío o una petición rechazada.

Cómo seguir

Lee la sección de la documentación de sqlite3 de Python sobre marcadores y luego prueba el mismo patrón con el driver de tu proyecto. Lee la hoja de referencia de prevención de SQL Injection de OWASP completa, sobre todo las secciones sobre procedimientos almacenados e identificadores dinámicos.

Vuelve al laboratorio de Juice Shop y resuelve los otros desafíos de SQL con el mismo cuidado. Para cada uno, anota la consulta que crees que ejecuta el servidor y luego compárala con el código fuente.

Agrega una prueba de regresión a tu código. La prueba debe enviar admin' -- o un valor parecido a cada función de consulta que acepte entrada del usuario y comprobar que la función no devuelve filas que no debería devolver.

Busca en tu código f-strings, formato con %, .format() y concatenaciones de cadenas cerca de execute, raw, text o extra. Cada coincidencia es un punto para revisar, no un error confirmado.

Las herramientas de análisis estático pueden marcar algunos de estos patrones, y un modelo de lenguaje puede ayudarte a leer constructores de consultas desconocidos. Ninguno reemplaza la prueba que muestra si tu consulta se comporta bien. La API de NotHoly es una opción para ese tipo de revisión, y solo es útil en la medida en que la prueba confirma el resultado.

El siguiente artículo de esta serie trata de Cross-Site Scripting (XSS), que tiene una forma parecida: texto no confiable termina interpretado como algo que no debería ser.

Fuentes

Fuentes

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. MITRE CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  3. OWASP Pwning OWASP Juice Shop companion guide
  4. Python sqlite3 DB-API 2.0 interface for SQLite databases

Más en Seguridad web y de APIs

Seguridad web y de APIs

OWASP Top 10 explicado para desarrolladores

Qué es el OWASP Top 10, qué significa cada categoría de 2025, cómo defenderse y cómo mapear retos de Juice Shop.

Principiante16 min de lectura

Seguridad web y de APIs

Qué es CSRF y cómo ayudan las cookies SameSite

CSRF usa las cookies de tu propio navegador en tu contra. Revisa las defensas de la más fuerte a la más débil y prueba SameSite con un laboratorio Flask.

Intermedio13 min de lectura