← CC3088

SQL Injection y Seguridad

Semestre 01, 2026

Vulnerabilidad

Problemática

El escenario

Una aplicación web recibe el nombre de usuario y contraseña, construye una consulta SQL y la ejecuta.


SELECT * FROM usuarios
WHERE username = 'admin' AND password = '1234';
        

Funciona perfectamente en condiciones normales. ¿Qué pasa si el usuario escribe algo inesperado?

Ataque

El input del atacante

El atacante escribe como nombre de usuario:


admin' --
        

La consulta resultante:


SELECT * FROM usuarios
WHERE username = 'admin' --' AND password = 'lo que sea';
        
-- inicia un comentario. Todo lo que viene después es ignorado. La condición de contraseña desaparece. El atacante entra sin conocer la contraseña.

La causa raíz

La aplicación mezcla datos del usuario con código SQL.

El motor de base de datos no distingue entre ambos.

Lo que era un dato se convierte en parte de la consulta.

SQL Injection explota exactamente esa confusión.
Definición

SQL Injection

Vulnerabilidad que permite a un atacante insertar o "inyectar" código SQL dentro de una consulta legítima, tomando control del SQL que se ejecuta en la base de datos.

Por qué es tan grave

  • Es una de las vulnerabilidades más antiguas y más explotadas de la historia.
  • Aparece consistentemente en el OWASP Top 10, la lista de las vulnerabilidades web más críticas.
  • Puede comprometer la confidencialidad, integridad y disponibilidad de los datos.
  • Una sola vulnerabilidad puede exponer toda la base de datos.

El vector de ataque

Cualquier punto donde la aplicación incorpora datos del usuario en una consulta SQL:

Formularios de login

Campos de búsqueda

Parámetros en la URL

Cabeceras HTTP

Cookies

Ataque 1

Bypass de autenticación

Código vulnerable

username = input("Usuario: ")
password = input("Contraseña: ")

query = (
    "SELECT * FROM usuarios "
    "WHERE username = '" + username +
    "' AND password = '" + password + "'"
)

resultado = db.execute(query)

if resultado:
    print("Acceso concedido")
            
El error

La consulta se construye por concatenación de strings. El motor no puede distinguir entre el código SQL y los datos del usuario.

Input malicioso y resultado


Usuario:    admin' --
Contraseña: (cualquier cosa)
        

La consulta resultante:


SELECT * FROM usuarios
WHERE username = 'admin' --' AND password = 'x';
        

El motor ejecuta solo WHERE username = 'admin'. Si existe ese usuario, el atacante obtiene acceso.

Variantes del bypass


' OR '1'='1
' OR 1=1 --
' OR 'x'='x
admin'/*
        

Todas logran lo mismo: hacer que la condición WHERE sea siempre verdadera.


SELECT * FROM usuarios
WHERE username = '' OR '1'='1' AND password = '';
-- Devuelve todos los usuarios
        
Ataque 2

Extracción de datos

El atacante no solo quiere entrar. Quiere los datos.

UNION-based injection

URL manipulada:


/productos?categoria=electronica' UNION SELECT null,username,password FROM usuarios --
        

Consulta original:


SELECT id, nombre, precio FROM productos
WHERE categoria = 'electronica'
        

Consulta inyectada:


SELECT id, nombre, precio FROM productos
WHERE categoria = 'electronica'
UNION SELECT null, username, password FROM usuarios --
        

La aplicación muestra los productos y los usuarios con sus contraseñas.

Alcance de la extracción

  • Nombres de todas las tablas: information_schema.tables
  • Columnas de cualquier tabla: information_schema.columns
  • Cualquier dato almacenado en la base de datos
  • En algunos casos: archivos del servidor
Ataque 3

Modificación y destrucción

Inyección en UPDATE

Código vulnerable:


nombre = input("Nuevo nombre: ")
query = (
    "UPDATE usuarios SET nombre = '"
    + nombre +
    "' WHERE id = 5"
)
                

Input malicioso:


admin', rol = 'superadmin' WHERE id = 5 --
                

Consulta resultante:


UPDATE usuarios
SET nombre = 'admin',
    rol = 'superadmin'
WHERE id = 5 --' WHERE id = 5
                

El atacante se asigna a sí mismo privilegios de administrador.

El peor caso


'; DROP TABLE usuarios; --
        

SELECT * FROM usuarios
WHERE username = ''; DROP TABLE usuarios; --'
        
Dependiendo de la configuración, esto puede ejecutar múltiples sentencias. La tabla de usuarios es eliminada permanentemente.
Taxonomía

Tipos de SQL Injection

In-band (clásica)

El resultado de la inyección se muestra directamente en la respuesta de la aplicación.

Es el tipo más fácil de explotar porque el atacante ve inmediatamente el resultado.

Error-based

Usa los mensajes de error de la base de datos para extraer información.

UNION-based

Usa UNION para unir resultados de otras tablas a la respuesta.

Blind (ciega)

La aplicación no muestra el resultado de la consulta ni errores.

El atacante hace preguntas de sí/no y deduce la información por el comportamiento.


¿El primer carácter del password es 'a'? → página carga normal = sí
¿El primer carácter del password es 'b'? → página da error    = no
        

Lento pero igualmente efectivo. Completamente automatizable.

Time-based blind

Cuando ni siquiera hay diferencia observable, el atacante usa el tiempo de respuesta.


'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END --
        

Si la página tarda 5 segundos en responder: la condición es verdadera.

El atacante extrae datos bit a bit midiendo tiempos de respuesta.

Out-of-band

Usa canales externos (DNS, HTTP) para exfiltrar datos.

Menos común, requiere configuración específica del servidor.

Análisis

La raíz del problema

SQL Injection no es un problema de validación de datos.

Es un problema de separación de código y datos.

El motor no sabe la diferencia

Para PostgreSQL, esta consulta es sintácticamente válida:


SELECT * FROM usuarios WHERE username = 'admin' --'
        

El motor no sabe que -- fue inyectado por un atacante.

Ejecuta lo que recibe.

El error de diseño

Construir consultas concatenando strings mezcla el código (la consulta) con los datos (el input).

Una vez mezclados, es imposible distinguirlos.

La solución no es "limpiar mejor el input".
La solución es nunca mezclarlos.
Prevención

Consultas parametrizadas

La defensa principal y más efectiva.

Vulnerable

query = (
    "SELECT * FROM usuarios "
    "WHERE username = '" + username + "'"
)
cursor.execute(query)
            
Concatenación

Los datos del usuario se insertan directamente en el string SQL. El motor interpreta todo como código.

Seguro

query = (
    "SELECT * FROM usuarios "
    "WHERE username = %s"
)
cursor.execute(query, (username,))
            
Parámetro

%s es un marcador de posición. El dato se envía al motor por separado, nunca se interpreta como SQL.

Cómo funciona internamente


Input del atacante: admin' --

Motor recibe:
  Consulta: SELECT * FROM usuarios WHERE username = $1
  Dato:     "admin' --"

Resultado: busca el usuario cuyo nombre es literalmente "admin' --"
        
El apóstrofo y el guion doble son tratados como texto, no como SQL.

Consultas parametrizadas en la práctica


# Con múltiples parámetros
cursor.execute(
    "SELECT * FROM productos WHERE categoria = %s AND precio < %s",
    (categoria, precio_max)
)

# Con INSERT
cursor.execute(
    "INSERT INTO pedidos (cliente_id, producto_id, cantidad) VALUES (%s, %s, %s)",
    (cliente_id, producto_id, cantidad)
)

# MAL — siempre vulnerable
query = f"SELECT * FROM usuarios WHERE id = {user_id}"

# BIEN — siempre seguro
cursor.execute("SELECT * FROM usuarios WHERE id = %s", (user_id,))
        
Prevención

Funciones almacenadas

Separan la lógica SQL de la aplicación. La app solo llama la función con parámetros.

Función para autenticación


CREATE OR REPLACE FUNCTION autenticar_usuario(
    p_username TEXT,
    p_password TEXT
)
RETURNS BOOLEAN AS $$
DECLARE
    v_hash TEXT;
BEGIN
    SELECT password_hash INTO v_hash
    FROM usuarios
    WHERE username = p_username;

    IF NOT FOUND THEN
        RETURN FALSE;
    END IF;

    RETURN v_hash = crypt(p_password, v_hash);
END;
$$ LANGUAGE plpgsql;
        

cursor.execute("SELECT autenticar_usuario(%s, %s)", (username, password))
resultado = cursor.fetchone()[0]
        

Función para búsqueda de productos


CREATE OR REPLACE FUNCTION buscar_productos(
    p_categoria TEXT,
    p_precio_max NUMERIC
)
RETURNS TABLE(id INT, nombre TEXT, precio NUMERIC) AS $$
BEGIN
    RETURN QUERY
    SELECT p.id, p.nombre, p.precio
    FROM productos p
    WHERE p.categoria = p_categoria
      AND p.precio <= p_precio_max;
END;
$$ LANGUAGE plpgsql;
        

cursor.execute(
    "SELECT * FROM buscar_productos(%s, %s)",
    (categoria, precio_max)
)
        
Prevención

Principio de mínimo privilegio

Segunda línea de defensa. Incluso si hay inyección, el daño queda contenido.

El rol de la aplicación debe ser restrictivo


CREATE ROLE app_tienda LOGIN PASSWORD 'clave_segura';

GRANT CONNECT ON DATABASE tienda TO app_tienda;
GRANT USAGE ON SCHEMA public TO app_tienda;

-- Solo lo que la aplicación necesita
GRANT SELECT ON TABLE productos, categorias TO app_tienda;
GRANT SELECT, INSERT ON TABLE pedidos, pedido_detalle TO app_tienda;
GRANT SELECT ON TABLE clientes TO app_tienda;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_tienda;

-- Sin acceso a: usuarios, salarios, logs internos
        

El impacto del mínimo privilegio

Sin mínimo privilegio (superusuario):


-- El atacante puede hacer todo
' UNION SELECT username, password
  FROM usuarios --

'; DROP TABLE pedidos; --

'; CREATE ROLE hacker
   SUPERUSER LOGIN
   PASSWORD 'pwned'; --
                

Con mínimo privilegio:


-- El atacante intenta
' UNION SELECT username, password
  FROM usuarios --
-- ERROR: permission denied for table usuarios

'; DROP TABLE pedidos; --
-- ERROR: permission denied for table pedidos
                
La inyección sigue siendo posible, pero el daño está contenido.

Separar roles por función


-- Solo lectura (reportes)
CREATE ROLE app_reportes LOGIN PASSWORD 'rpt_clave';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_reportes;

-- Operaciones de escritura (la app web)
CREATE ROLE app_web LOGIN PASSWORD 'web_clave';
GRANT SELECT ON TABLE productos, clientes TO app_web;
GRANT INSERT ON TABLE pedidos TO app_web;

-- Administración (solo humanos, nunca apps)
CREATE ROLE admin_db LOGIN PASSWORD 'adm_clave' CREATEROLE;
        

Si la app web es comprometida, el atacante no puede leer datos de reportes ni administrar roles.

Prevención

Validación de entrada

No reemplaza las consultas parametrizadas. Es una capa adicional de defensa.

Validar el tipo de dato


def obtener_producto(producto_id):
    if not str(producto_id).isdigit():
        raise ValueError("ID de producto inválido")

    cursor.execute(
        "SELECT * FROM productos WHERE id = %s",
        (producto_id,)
    )
        

Validar con listas blancas

Para valores que deben ser exactamente uno de un conjunto conocido:


CATEGORIAS_VALIDAS = {'electronica', 'ropa', 'alimentos', 'libros'}

def buscar_por_categoria(categoria):
    if categoria not in CATEGORIAS_VALIDAS:
        raise ValueError("Categoría no válida")

    cursor.execute(
        "SELECT * FROM productos WHERE categoria = %s",
        (categoria,)
    )
        

Sanitizar no es suficiente


# Esto NO es seguro — hay formas de evadirlo
username = username.replace("'", "''")
query = "SELECT * FROM usuarios WHERE username = '" + username + "'"
        
Intentar "escapar" o "limpiar" el input manualmente es propenso a errores. Siempre usar consultas parametrizadas. La validación es complementaria, no sustituta.
Errores frecuentes

Errores comunes

Error 1

Concatenar strings para construir SQL


# Vulnerable — siempre
query = "SELECT * FROM " + tabla + " WHERE id = " + id
cursor.execute(query)
        
No hay forma segura de construir SQL por concatenación cuando los datos vienen del usuario.
Error 2

Confiar en que el frontend valida

Un atacante no usa el formulario web.

Envía peticiones HTTP directamente con herramientas como curl o Burp Suite.

La validación del frontend es solo experiencia de usuario, no seguridad.
Error 3

Mostrar mensajes de error de la base de datos


ERROR: syntax error at or near "'"
LINE 1: SELECT * FROM usuarios WHERE username = 'admin''
        

Los mensajes de error revelan la estructura de la consulta. El atacante usa esa información para ajustar su ataque.

En producción: capturar errores internamente, mostrar mensajes genéricos al usuario.
Error 4

Usar el superusuario en aplicaciones

Si la aplicación se conecta con postgres o un rol con SUPERUSER:

Un ataque exitoso tiene acceso ilimitado a toda la base de datos.

Crear siempre un rol específico con mínimo privilegio.

Error 5

Creer que el ORM protege automáticamente

Los ORM protegen cuando se usan correctamente, pero permiten consultas crudas:


# Django ORM — seguro
User.objects.filter(username=username)

# Django ORM — vulnerable (raw query con formato)
User.objects.raw(
    f"SELECT * FROM users WHERE username = '{username}'"
)
        
Estrategia

Defensa en profundidad

Ninguna medida por sí sola es suficiente. La seguridad real combina múltiples capas.

Las capas de defensa

CapaMedidaQué previene
CódigoConsultas parametrizadasInyección de SQL
CódigoValidación de entradaInputs malformados
Base de datosMínimo privilegioDaño si hay inyección
Base de datosRoles separados por funciónEscalada lateral
AplicaciónMensajes de error genéricosReconocimiento del atacante
InfraestructuraFirewall de aplicación web (WAF)Ataques conocidos
MonitoreoLogs de consultas inusualesDetección en curso

El principio

Asumir que cada capa puede fallar.

Diseñar el sistema para que, si falla una capa, las demás contengan el daño.

Resumen

¿Qué puede salir mal?

  • Consultas por concatenación → vulnerabilidad directa a SQL Injection
  • Rol de la aplicación con ALL PRIVILEGES → daño ilimitado si hay inyección exitosa
  • Contraseñas almacenadas en texto plano → inyección expone credenciales reales
  • Mensajes de error técnicos al usuario → el atacante mapea la estructura de la base de datos
  • Validar solo en el frontend → ignorado trivialmente con cualquier cliente HTTP
  • Mismo rol para lectura y escritura → innecesariamente amplio
  • Creer que el ORM es inmune → las raw queries son igual de vulnerables
  • No registrar consultas en logs → los ataques pasan desapercibidos