SQL Injection

Cómo detectar y prevenir vulnerabilidades SQL Injection mediante consultas parametrizadas y un acceso seguro a las bases de datos.

1. El problema

1.1. ¿Qué es SQL Injection?

SQL Injection (SQLi) es una vulnerabilidad clásica que permite a un atacante manipular las consultas que una aplicación ejecuta contra su base de datos, introduciendo instrucciones SQL a través de campos o parámetros que la aplicación no trata correctamente.

Imaginemos una aplicación que busca clientes por su correo electrónico. Cuando el usuario escribe ana@example.com, el servidor ejecuta una consulta como esta:

SELECT id, name, email
FROM customers
WHERE email = 'ana@example.com';

Hasta aquí, todo correcto. Pero ¿qué ocurre si la aplicación construye esa consulta concatenando directamente el valor que recibe del usuario? Un atacante podría introducir algo así:

' OR 1=1 -- 

Y la consulta terminaría convirtiéndose en:

SELECT id, name, email
FROM customers
WHERE email = '' OR 1=1 -- ';

Y zasca, ya la hemos liado:

  • La primera comilla ' cierra la cadena que contenía el correo electrónico.
  • OR 1=1 introduce una condición que siempre es verdadera.
  • -- convierte el resto de la línea en un comentario SQL, anulando la comilla final.

El resultado es que la consulta deja de buscar un cliente concreto y devuelve todos los registros de la tabla, incluidos los valores sensibles que pudiera haber almacenados. La base de datos está interpretando como código algo que debería tratar exclusivamente como información.

1.2. En código

Vamos a construir un ejemplo sencillo con FastAPI y SQLite. Un endpoint recibe un correo electrónico y busca al cliente correspondiente:

import sqlite3
from fastapi import FastAPI

app = FastAPI()

@app.get("/api/customers")
def get_customers(email: str):
    conn = sqlite3.connect("shop.db")

    try:
        # Tal cual nos llega email, lo metemos en la query
        query = f"""
            SELECT id, name, email
            FROM customers
            WHERE email = '{email}'
        """

        rows = conn.execute(query).fetchall()

        return [
            {"id": r[0], "name": r[1], "email": r[2]}
            for r in rows
        ]
    finally:
        conn.close()

El problema está en esta línea:

WHERE email = '{email}'

Una f-string de Python introduce directamente un valor externo dentro de la instrucción SQL.

Con una petición normal, el endpoint devuelve el cliente solicitado. Pero si enviamos el valor malicioso anterior, la base de datos ejecuta la consulta modificada y devuelve todos los clientes. Y lo peor es que, desde el punto de vista de SQLite, la consulta es perfectamente válida: no tiene forma de saber que esa condición adicional viene de un atacante.

1.3. Consecuencias

Las consecuencias de una SQL Injection pueden ser muy graves y dependen tanto de la consulta vulnerable como de los permisos con los que la aplicación accede a la base de datos.

Lo más habitual es la lectura de información confidencial: datos personales, información financiera o credenciales almacenadas (con un UNION SELECT se pueden incluso leer otras tablas distintas de la consultada). Pero también se pueden alterar registros, precios, permisos o datos de usuarios, y borrar registros o tablas si la técnica y los permisos lo permiten. Ciertas consultas vulnerables permiten además saltarse la autenticación e iniciar sesión sin conocer la contraseña. Y en algunos motores y configuraciones, la inyección puede llegar a usar funcionalidades del sistema operativo (el caso clásico es xp_cmdshell en SQL Server), lo que equivale a comprometer el servidor.

Eso sí, no todas las SQL Injection permiten todo. Una vulnerabilidad en una consulta de lectura no implica que podamos borrar una tabla, y los límites dependen del motor, del driver y del contexto de la inyección. El módulo sqlite3 de Python, por ejemplo, rechaza varias sentencias en una misma llamada a execute, así que el clásico ; DROP TABLE no funcionaría en nuestro ejemplo, aunque el UNION SELECT sí.

1.4. Cómo detectarlo

Lo primero es localizar los puntos donde la aplicación recibe información externa y la usa para construir consultas SQL:

GET /api/customers?email=...
GET /api/products?category=...
GET /api/orders?sort=...
POST /api/search

En entornos de prueba, una comilla simple es un buen primer sondeo: si aparecen errores de sintaxis SQL, hay algo que investigar. También puedes comparar respuestas con condiciones verdaderas y falsas (por ejemplo ' AND 1=1 -- frente a ' AND 1=2 -- ). Si al cambiar el parámetro cambia de forma inesperada el conjunto de resultados, podría existir una inyección.

Pero que no veas errores no garantiza que la aplicación sea segura. Existen técnicas como la Blind SQL Injection, en las que la base de datos no devuelve la información directamente y el atacante la deduce a partir de diferencias en las respuestas o en los tiempos de ejecución.

Además de las pruebas dinámicas, hay que revisar el código buscando patrones como estos:

# Concatenación
query = "SELECT * FROM users WHERE id = " + user_id

# Interpolación
query = f"SELECT * FROM users WHERE name = '{name}'"

# Formato de cadenas
query = "SELECT * FROM users WHERE id = {}".format(user_id)

Las herramientas SAST, como Semgrep, ayudan a encontrarlos en el código, y las DAST, como Burp Suite (o sqlmap, siempre en pruebas autorizadas), a comprobar si son explotables.

1.5. Errores frecuentes

El clásico es confiar en las validaciones del frontend: un atacante puede enviar peticiones directamente al backend. El segundo, intentar limpiar las entradas a mano eliminando comillas o caracteres sospechosos, una defensa frágil que además depende del motor SQL.

También hay confianzas mal puestas. En el ORM: SQLAlchemy, Hibernate y compañía permiten construir consultas seguras, pero ejecutan SQL vulnerable si les concatenas cadenas. Y en la cuenta de base de datos: si la aplicación se conecta como administrador, una inyección tiene consecuencias mucho peores de las necesarias.

Por último, creer que SQLi solo afecta a formularios. Los datos peligrosos pueden llegar por parámetros de URL, cabeceras, cuerpos JSON e incluso desde información previamente almacenada en la propia base de datos (lo que se conoce como second-order SQL Injection).

2. La solución

La defensa de fondo son las consultas parametrizadas (Parameterized Queries o Prepared Statements), que separan la estructura de la consulta SQL de los valores que recibe. Corregimos el ejemplo de FastAPI:

import sqlite3
from fastapi import FastAPI

app = FastAPI()

@app.get("/api/customers")
def get_customers(email: str):
    conn = sqlite3.connect("shop.db")

    try:
        query = """
            SELECT id, name, email
            FROM customers
            WHERE email = ?
        """

        rows = conn.execute(
            query,
            (email,)
        ).fetchall()

        return [
            {"id": r[0], "name": r[1], "email": r[2]}
            for r in rows
        ]
    finally:
        conn.close()

Todo está en dos detalles. El marcador en la consulta:

WHERE email = ?

Y la forma de ejecutarla, pasando el valor aparte:

conn.execute(query, (email,))

Ahora la instrucción SQL y sus parámetros viajan por separado, y SQLite enlaza el valor al marcador sin incorporarlo a la sintaxis. (El marcador cambia según el driver: ? en sqlite3, %s en psycopg2 o mysql-connector. Y según el driver, el enlace lo hace el servidor o el propio driver, pero en ningún caso el valor se trata como código.)

Si el usuario introduce de nuevo:

' OR 1=1 -- 

SQLite no lo interpretará como una condición, sino como un valor de texto, y buscará literalmente un correo con esos caracteres. La consulta no devolverá ningún cliente, salvo que exista uno con ese valor exacto. La estructura SQL ya no puede modificarse mediante el parámetro email.

Con SQLAlchemy obtenemos el mismo comportamiento a través de su API de consultas:

from sqlalchemy import select

stmt = select(Customer).where(
    Customer.email == email
)

customers = db.execute(
    stmt
).scalars().all()

SQLAlchemy genera una consulta con parámetros enlazados, sin que tengamos que construir ninguna cadena SQL a mano. Si necesitas SQL en crudo, usa text() con parámetros con nombre (text("... WHERE email = :email")) y no metas nunca una f-string dentro de text(), porque reintroduce el problema.

Hay dos medidas más. La primera es el principio de mínimo privilegio: la cuenta que usa la aplicación debe tener únicamente los permisos necesarios. Si un servicio solo consulta información, no debería poder modificar tablas ni borrar registros. De paso, conviene no devolver al cliente los errores de la base de datos, que facilitan el sondeo que describíamos antes.

La segunda es saber qué no se puede parametrizar. Los parámetros enlazados valen para valores, no para nombres de tablas o columnas ni para palabras clave como ASC y DESC. Si el usuario puede elegir la columna de ordenación, hay que seleccionarla a partir de una lista cerrada de opciones que defina la aplicación:

ALLOWED_SORT = {"name": "name", "email": "email", "created": "created_at"}

# Lo que va a la consulta sale de nuestra lista, nunca del usuario
column = ALLOWED_SORT.get(sort, "name")
query = f"SELECT id, name, email FROM customers ORDER BY {column}"

La validación de entradas es útil, pero la protección real consiste en separar siempre el código SQL de los valores que procesa.

La clave de SQL Injection: nunca construyas instrucciones SQL concatenando datos externos.

Para saber más

OWASP. SQL Injection Prevention Cheat Sheet.

PortSwigger Web Security Academy. SQL Injection.