Semestre 01, 2026
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?
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 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.
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.
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
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.
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.
' 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
El atacante no solo quiere entrar. Quiere los datos.
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.
information_schema.tablesinformation_schema.columnsCó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.
'; DROP TABLE usuarios; --
SELECT * FROM usuarios
WHERE username = ''; DROP TABLE usuarios; --'
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.
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.
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.
Usa canales externos (DNS, HTTP) para exfiltrar datos.
Menos común, requiere configuración específica del servidor.
SQL Injection no es un problema de validación de datos.
Es un problema de separación de código y datos.
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.
Construir consultas concatenando strings mezcla el código (la consulta) con los datos (el input).
Una vez mezclados, es imposible distinguirlos.
La defensa principal y más efectiva.
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.
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.
Input del atacante: admin' --
Motor recibe:
Consulta: SELECT * FROM usuarios WHERE username = $1
Dato: "admin' --"
Resultado: busca el usuario cuyo nombre es literalmente "admin' --"
# 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,))
Separan la lógica SQL de la aplicación. La app solo llama la función con parámetros.
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]
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)
)
Segunda línea de defensa. Incluso si hay inyección, el daño queda contenido.
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
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
-- 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.
No reemplaza las consultas parametrizadas. Es una capa adicional de defensa.
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,)
)
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,)
)
# Esto NO es seguro — hay formas de evadirlo
username = username.replace("'", "''")
query = "SELECT * FROM usuarios WHERE username = '" + username + "'"
# Vulnerable — siempre
query = "SELECT * FROM " + tabla + " WHERE id = " + id
cursor.execute(query)
Un atacante no usa el formulario web.
Envía peticiones HTTP directamente con herramientas como curl o Burp Suite.
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.
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.
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}'"
)
Ninguna medida por sí sola es suficiente. La seguridad real combina múltiples capas.
| Capa | Medida | Qué previene |
|---|---|---|
| Código | Consultas parametrizadas | Inyección de SQL |
| Código | Validación de entrada | Inputs malformados |
| Base de datos | Mínimo privilegio | Daño si hay inyección |
| Base de datos | Roles separados por función | Escalada lateral |
| Aplicación | Mensajes de error genéricos | Reconocimiento del atacante |
| Infraestructura | Firewall de aplicación web (WAF) | Ataques conocidos |
| Monitoreo | Logs de consultas inusuales | Detección en curso |
Asumir que cada capa puede fallar.
Diseñar el sistema para que, si falla una capa, las demás contengan el daño.