Skip to content

Inyección SQL ​

La inyección SQL lleva más de 25 años en la lista de ataques más peligrosos y sigue apareciendo en sitios nuevos todos los días. Permite a un atacante leer toda tu base de datos, saltarse el login, modificar precios o borrar tablas completas. La buena noticia: se elimina por completo con consultas preparadas.

Concepto ​

Imagina que le dejas a tu asistente una nota: "Entrégale el paquete a ________". El cliente escribe en el espacio: "Juan. Y además entrégale todos los paquetes del almacén". Si tu asistente lee la nota completa como una sola instrucción, obedece todo.

Eso pasa cuando concatenas texto del usuario dentro de una consulta SQL: el motor de base de datos no sabe dónde terminan tus instrucciones y dónde empiezan los datos.

php
// Lo que escribiste
$sql = "SELECT * FROM usuarios WHERE email = '" . $email . "'";

// Si $email = "x' OR '1'='1", la consulta final es:
// SELECT * FROM usuarios WHERE email = 'x' OR '1'='1'
// '1'='1' siempre es verdadero → devuelve TODOS los usuarios

Las consultas preparadas separan las dos cosas: primero envías la estructura de la consulta con marcadores (? o :nombre) y después, por separado, los datos. El motor nunca interpreta los datos como SQL, sin importar qué contengan.

Ejemplo: login vulnerable ​

php
<?php
require 'db.php';

$email    = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

// ❌ Concatenación directa
$sql = "SELECT * FROM usuarios WHERE email = '$email' AND password = '$password'";
$resultado = $pdo->query($sql);

if ($resultado->fetch()) {
    echo 'Bienvenido';
}
// Con email = admin@sitio.com'-- (y cualquier contraseña) la consulta queda:
// SELECT * FROM usuarios WHERE email = 'admin@sitio.com'-- ' AND password = '...'
// "--" comenta el resto: ¡entra como admin sin contraseña!
js
app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // ❌ Template string con datos del usuario dentro del SQL
  const [filas] = await pool.query(
    `SELECT * FROM usuarios WHERE email = '${email}' AND password = '${password}'`
  );

  if (filas.length) return res.send('Bienvenido');
  res.status(401).send('Datos incorrectos');
});

Además del login, un atacante puede usar UNION SELECT para leer otras tablas, o técnicas "a ciegas" (blind SQLi) que extraen datos carácter por carácter observando tiempos de respuesta. Herramientas automáticas como sqlmap lo hacen en minutos.

Ejemplo: versión segura ​

php
<?php
// Conexión PDO configurada correctamente
$dsn = 'mysql:host=localhost;dbname=tienda;charset=utf8mb4';

$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // errores como excepciones
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false, // preparadas reales en el servidor MySQL
]);
php
<?php
session_start();
require 'db.php';

$email    = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

// ✅ Marcador con nombre; el email viaja aparte
$stmt = $pdo->prepare('SELECT id, password_hash FROM usuarios WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$usuario = $stmt->fetch();

// ✅ Las contraseñas se comparan con password_verify (lección 05)
if ($usuario && password_verify($password, $usuario['password_hash'])) {
    session_regenerate_id(true);
    $_SESSION['usuario_id'] = $usuario['id'];
    header('Location: /panel.php');
    exit;
}

$error = 'Correo o contraseña incorrectos';
js
import mysql from 'mysql2/promise';
import bcrypt from 'bcrypt';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
});

app.post('/login', async (req, res) => {
  const { email = '', password = '' } = req.body;

  // ✅ execute() usa consultas preparadas reales con "?"
  const [filas] = await pool.execute(
    'SELECT id, password_hash FROM usuarios WHERE email = ? LIMIT 1',
    [email]
  );

  const usuario = filas[0];
  if (usuario && (await bcrypt.compare(password, usuario.password_hash))) {
    req.session.regenerate(() => {
      req.session.usuarioId = usuario.id;
      res.redirect('/panel');
    });
    return;
  }
  res.status(401).send('Correo o contraseña incorrectos');
});

Casos especiales ​

ORDER BY, nombres de columnas y tablas ​

Los marcadores solo sirven para valores, no para nombres de columnas, tablas ni palabras clave como ASC/DESC. Para eso usa una lista blanca:

php
<?php
// ?orden=precio&dir=desc
$columnasPermitidas = ['nombre', 'precio', 'creado_en'];
$orden = in_array($_GET['orden'] ?? '', $columnasPermitidas, true) ? $_GET['orden'] : 'nombre';
$dir   = strtoupper($_GET['dir'] ?? '') === 'DESC' ? 'DESC' : 'ASC';

// Ahora sí es seguro interpolar: solo puede valer algo de la lista
$stmt = $pdo->prepare("SELECT * FROM productos WHERE categoria_id = ? ORDER BY $orden $dir");
$stmt->execute([(int) ($_GET['categoria'] ?? 0)]);

Cláusula IN con varios valores ​

php
<?php
$ids = array_map('intval', $_POST['ids'] ?? []); // [3, 8, 15]
if ($ids === []) {
    exit('Sin elementos');
}

// Genera "?,?,?" según cuántos haya
$marcadores = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("DELETE FROM carrito WHERE usuario_id = ? AND producto_id IN ($marcadores)");
$stmt->execute([$_SESSION['usuario_id'], ...$ids]);
js
// En Node con mysql2
const ids = (req.body.ids ?? []).map(Number).filter(Number.isInteger);
const marcadores = ids.map(() => '?').join(',');
await pool.execute(
  `DELETE FROM carrito WHERE usuario_id = ? AND producto_id IN (${marcadores})`,
  [req.session.usuarioId, ...ids]
);

LIKE ​

php
<?php
// El comodín % va en el VALOR, no en la consulta
$stmt = $pdo->prepare('SELECT * FROM productos WHERE nombre LIKE ?');
$stmt->execute(['%' . $_GET['q'] . '%']);

ORMs y query builders ​

Laravel Eloquent, Prisma, Sequelize o Knex usan consultas preparadas por debajo... salvo en sus métodos "raw":

js
// Prisma
await prisma.$queryRaw`SELECT * FROM usuarios WHERE email = ${email}`;        // ✅ seguro (tagged template)
await prisma.$queryRawUnsafe(`SELECT * FROM usuarios WHERE email = '${email}'`); // ❌ inyectable
php
// Laravel
User::whereRaw('email = ?', [$email])->first();     // ✅ con bindings
User::whereRaw("email = '$email'")->first();       // ❌ inyectable

Error común

Creer que addslashes(), mysql_real_escape_string() o quitar comillas a mano es suficiente. Esas técnicas fallan con ciertas codificaciones de caracteres y con valores numéricos sin comillas (WHERE id = $id no necesita comillas para ser atacado: 1 OR 1=1). Usa siempre consultas preparadas.

Error común

Usar $pdo->query() o pool.query() con variables dentro. query() es para consultas fijas sin datos externos. Si la consulta lleva datos, usa prepare() + execute() (PHP) o execute() (mysql2).

Más capas de defensa ​

  • Mínimo privilegio: el usuario de la base de datos de la web no debe poder hacer DROP ni leer mysql.user.
  • Errores ocultos: nunca muestres el mensaje de error de MySQL al usuario; regístralo en un log.
php
<?php
try {
    $stmt = $pdo->prepare('SELECT * FROM productos WHERE id = ?');
    $stmt->execute([$id]);
} catch (PDOException $e) {
    error_log($e->getMessage()); // al log del servidor
    http_response_code(500);
    exit('Ocurrió un error. Intenta más tarde.'); // mensaje genérico
}
  • Validación de tipos: si esperas un ID entero, conviértelo con (int) o valida con filter_var($x, FILTER_VALIDATE_INT).

Buena práctica

Busca en tu proyecto todas las consultas que usan concatenación. En VS Code, una búsqueda con expresión regular como query\(.*\$ (PHP) o query\(.*\$\{ (Node) te muestra rápidamente los sitios sospechosos.

Inyección de comandos (pariente cercano) ​

El mismo problema aparece si pasas datos del usuario a la terminal del sistema:

php
<?php
$host = $_GET['host'] ?? '';

// ❌ host=google.com;rm -rf /  ejecutaría dos comandos
// system("ping -c 1 $host");

// ✅ Valida y escapa el argumento
if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
    exit('Host inválido');
}
system('ping -c 1 ' . escapeshellarg($host));
js
import { execFile } from 'node:child_process';

// ❌ exec(`ping -c 1 ${host}`) pasa por la shell: inyectable
// ✅ execFile pasa los argumentos como lista, sin shell
execFile('ping', ['-c', '1', host], (err, salida) => {
  if (err) return res.status(400).send('Error');
  res.type('text').send(salida);
});

Ejercicios ​

  1. Convierte esta consulta a preparada con PDO: $pdo->query("SELECT * FROM posts WHERE autor_id = $autor AND estado = '$estado'").
  2. En Express, escribe una búsqueda de productos por nombre (?q=) que también permita ordenar por precio o nombre mediante ?orden=.
  3. ¿Por qué WHERE id = $id es vulnerable aunque no tenga comillas? Da un payload.
Ver solución
  1. Con marcadores con nombre:
php
<?php
$stmt = $pdo->prepare('SELECT * FROM posts WHERE autor_id = :autor AND estado = :estado');
$stmt->execute([
    'autor'  => (int) $autor,
    'estado' => $estado,
]);
$posts = $stmt->fetchAll();
  1. Búsqueda con lista blanca:
js
app.get('/productos', async (req, res) => {
  const q = String(req.query.q ?? '');
  const orden = ['precio', 'nombre'].includes(req.query.orden) ? req.query.orden : 'nombre';

  const [filas] = await pool.execute(
    `SELECT id, nombre, precio FROM productos WHERE nombre LIKE ? ORDER BY ${orden}`,
    [`%${q}%`]
  );
  res.json(filas);
});
  1. Porque el atacante no necesita cerrar comillas: con id=1 OR 1=1 la consulta devuelve todas las filas, y con id=0 UNION SELECT email, password_hash FROM usuarios puede leer otras tablas.

Resumen ​

  • La inyección SQL ocurre al mezclar datos del usuario con el texto de la consulta.
  • La solución es usar consultas preparadas: prepare() + execute() en PDO, execute() en mysql2.
  • Configura PDO con ERRMODE_EXCEPTION y EMULATE_PREPARES en false.
  • Para columnas, tablas u ORDER BY usa listas blancas.
  • Cuidado con los métodos "raw" de los ORMs.
  • Oculta errores de base de datos y aplica mínimo privilegio.

Academia Web — aprende, copia y construye.