Saltar al contenido principal

SQL Injection

SQL Injection (SQLi) es una vulnerabilidad que permite a un atacante interferir en las consultas SQL que una aplicación realiza a su base de datos. Al inyectar código SQL malicioso a través de entradas del usuario, el atacante puede:

  • Leer datos sensibles que no debería ver (contraseñas, datos personales, tarjetas…).
  • Modificar o eliminar datos.
  • Saltarse la autenticación.
  • En algunos casos, ejecutar comandos en el sistema operativo del servidor.

Es una de las vulnerabilidades más antiguas y más explotadas. Forma parte del OWASP Top 10 desde su primera edición.

Cómo funciona

La causa raíz es siempre la misma: concatenar directamente la entrada del usuario en una consulta SQL.

Ejemplo básico: bypass de login

// Código vulnerable
app.post('/login', async (req, res) => {
const { usuario, password } = req.body;

const query = `SELECT * FROM usuarios
WHERE usuario = '${usuario}'
AND password = '${password}'`;

const resultado = await db.query(query);

if (resultado.length > 0) {
res.json({ ok: true, token: generarToken(resultado[0]) });
} else {
res.status(401).json({ error: 'Credenciales incorrectas' });
}
});

Si un atacante introduce en el campo usuario el valor:

' OR '1'='1' --

La consulta resultante sería:

SELECT * FROM usuarios
WHERE usuario = '' OR '1'='1' --' AND password = 'lo_que_sea'
  • OR '1'='1' siempre es verdadero y devuelve todos los usuarios.
  • -- comenta el resto de la consulta (incluida la verificación de contraseña).
  • El atacante entra como el primer usuario de la tabla (normalmente el administrador).

Tipos de SQL Injection

In-band SQLi

Es el más común. El resultado de la inyección se devuelve directamente en la respuesta de la aplicación.

Se puede dividir en dos tipos:

  • Error-based: el atacante provoca errores SQL que revelan información sobre la estructura de la base de datos.

    ' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) --
  • Union-based: se usa UNION SELECT para extraer datos de otras tablas.

    -- Input malicioso en el campo id:
    -- 1 UNION SELECT usuario, password, null FROM usuarios --

    SELECT nombre, descripcion, precio
    FROM productos
    WHERE id = 1 UNION SELECT usuario, password, null FROM usuarios --

Blind SQLi

La aplicación no devuelve el resultado de la consulta directamente, pero el atacante puede inferir información mediante respuestas de tipo sí/no o midiendo tiempos de respuesta.

Se puede dividir en dos tipos:

  • Boolean-based:

    ' AND 1=1 --   → Respuesta normal (condición verdadera)
    ' AND 1=2 -- → Respuesta vacía o diferente (condición falsa)
  • Time-based:

    -- Si la base de datos tarda 5 segundos en responder, la condición es verdadera
    '; IF (1=1) WAITFOR DELAY '0:0:5' --

Out-of-band SQLi

El atacante extrae datos a través de canales alternativos (peticiones DNS, HTTP) en lugar de la respuesta directa. Menos común, pero posible en configuraciones específicas.

Impacto real: extracción de una tabla completa

Con UNION SELECT, un atacante podría:

  1. Descubrir el número de columnas:

    ' ORDER BY 1-- ' ORDER BY 2-- ' ORDER BY 3-- (hasta que falle)
  2. Identificar qué columnas muestran datos:

    ' UNION SELECT null, null, null --
  3. Extraer el nombre de las tablas:

    ' UNION SELECT table_name, null, null FROM information_schema.tables --
  4. Extraer columnas de una tabla concreta:

    ' UNION SELECT column_name, null, null
    FROM information_schema.columns
    WHERE table_name = 'usuarios' --
  5. Extraer los datos:

    ' UNION SELECT usuario, password, email FROM usuarios --

Cómo prevenir SQL Injection

Consultas parametrizadas (Prepared Statements)

Es la defensa principal y más efectiva. Los parámetros se envían al motor SQL separados de la consulta, por lo que nunca se interpretan como código SQL.

Con mysql2:

// Seguro: parámetros separados de la consulta
const [rows] = await connection.execute(
'SELECT * FROM usuarios WHERE usuario = ? AND password = ?',
[usuario, password] // Los ? se sustituyen de forma segura
);

Con pg (PostgreSQL):

const result = await client.query(
'SELECT * FROM usuarios WHERE usuario = $1 AND password = $2',
[usuario, password]
);

Con Sequelize:

// Un ORM usa consultas parametrizadas internamente
const usuario = await Usuario.findOne({
where: { usuario: req.body.usuario, password: req.body.password }
});

Con Knex.js:

const usuario = await knex('usuarios')
.where({ usuario: req.body.usuario, password: req.body.password })
.first();

ORM / Query Builders

Usar un ORM (Sequelize, Prisma, TypeORM) o un query builder (Knex) reduce drásticamente el riesgo porque abstraen la construcción de SQL y usan parámetros automáticamente.

// Con Prisma
const usuario = await prisma.usuario.findFirst({
where: {
email: req.body.email
}
});
Importante

Incluso con un ORM, si usas consultas SQL en crudo ($queryRaw, sequelize.query…), debes seguir usando parámetros.

Validación y saneamiento de entradas

Complementa las consultas parametrizadas. Rechaza entradas que no tienen el formato esperado:

import validator from 'express-validator'
const { body, validationResult } = validator

app.post('/login', [
body('usuario').isEmail().normalizeEmail(),
body('password').isLength({ min: 8, max: 128 })
], (req, res) => {
const errores = validationResult(req);
if (!errores.isEmpty()) {
return res.status(400).json({ errores: errores.array() });
}
// ...
});

Principio de mínimo privilegio en la base de datos

El usuario de base de datos que usa la aplicación no debería tener permisos innecesarios.

Nunca se debe usar root o un usuario con todos los privilegios. Se debe crear un usuario con solo los permisos necesarios.

-- Crear un usuario con solo los permisos necesarios
CREATE USER 'app_usuario'@'localhost' IDENTIFIED BY 'contraseña_segura';

-- Solo SELECT, INSERT, UPDATE, DELETE en las tablas necesarias
GRANT SELECT, INSERT, UPDATE, DELETE ON mi_app.* TO 'app_usuario'@'localhost';

-- Sin DROP, ALTER, CREATE, FILE...
FLUSH PRIVILEGES;

No exponer errores de SQL al cliente

try {
const resultado = await db.query(...);
} catch (err) {
// Nunca enviar el error SQL al cliente
// res.status(500).json({ error: err.message });

// Log interno + mensaje genérico
console.error('Error DB:', err);
res.status(500).json({ error: 'Error interno del servidor' });
}