Cómo proteger tus aplicaciones web de inyecciones SQL

En 2018 escribí que había que probar tu web contra SQL Injection y lo dejé ahí, en una línea. Esta es la parte que faltaba, con las tres cosas que crees que te protegen.

Por Jorge Castro 6 min de lectura
Cómo proteger tus aplicaciones web de inyecciones SQL

Hace unos años escribí acá mismo un artículo sobre seguridad, y en medio de una lista puse que como desarrollador debías poder probar tu web contra ataques de SQL Injection y XSS. Una línea. Eso fue todo lo que dije del tema, y llevo desde entonces con la sensación de haber dejado la puerta entreabierta, porque decirle a alguien que “debe probar” sin explicarle contra qué está probando no sirve de nada.

Así que esta es esa línea, desarrollada.

Y parto por lo incómodo: la inyección SQL es un problema resuelto desde hace más de veinte años, la solución cabe en dos líneas de código y sigue apareciendo en el top 10 de OWASP año tras año. Eso ya te dice que el problema no es técnico.

Qué es, en una consulta

Imagina el login más simple que puedas escribir:

SELECT * FROM users WHERE username = '$username' AND password = '$password';

Alguien escribe en el campo de usuario esto:

' OR '1'='1

Y tu consulta se convierte en esta otra:

SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '';

Devuelve todos los usuarios de la tabla, y el atacante entra sin tener ninguna credencial.

Fíjate en lo que pasó, porque es todo el asunto: el texto que escribió el usuario dejó de ser un dato y pasó a ser parte de la instrucción. Tu base de datos no tiene cómo saber que esa comilla venía de un formulario y no de ti. Hace exactamente lo que le pediste.

”Yo uso Laravel, el framework me protege”

Es verdad, y por eso mismo es la creencia más peligrosa de las tres.

Eloquent y el Query Builder usan consultas parametrizadas por defecto, así que mientras escribas esto estás cubierto:

// Eloquent, seguro por defecto
$user = User::where('email', $email)->first();

// Query Builder, también seguro
$user = DB::table('users')->where('email', $email)->first();

El problema es el día que el Query Builder no te alcanza, y ese día llega siempre: necesitas un JOIN raro, o una función de la base de datos que el ORM no expone, o un reporte con tres subconsultas que el cliente pidió para ayer, o simplemente quieres ordenar por una columna que viene del front, y entonces escribes esto:

// Acá se acabó la protección
$users = DB::select("SELECT * FROM users WHERE email = '$email'");
$users = DB::table('users')->orderByRaw("$columna $direccion")->get();

Todo lo que termine en Raw te devuelve el control y la responsabilidad. Y esas líneas no aparecen el primer día, aparecen dieciocho meses después, cuando el proyecto ya creció y alguien tenía apuro.

La versión parametrizada sin ORM, para que la tengas al lado:

// Mal, concatenando
$query = "SELECT * FROM users WHERE email = '$email'";

// Bien, con prepared statement
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);

La diferencia no está en que el segundo sea más seguro por elegante. Está en que el motor recibe primero la estructura de la consulta y después los valores, así que ya no hay forma de que un valor se convierta en estructura.

Abre tu proyecto y busca DB::select, DB::raw, whereRaw, orderByRaw y selectRaw. Cada resultado es un lugar donde estás solo.

”Valido los inputs antes de guardarlos”

Muy bien, y sigue haciéndolo, pero eso no es lo que te protege de esto.

$email = filter_var($input, FILTER_VALIDATE_EMAIL);
$id = filter_var($input, FILTER_VALIDATE_INT);

Validar sirve para que no te entre basura, para que un id sea un número y para que un correo tenga forma de correo. Es higiene y la necesitas. Pero la validación es una lista de lo que aceptas, y siempre va a haber un campo donde tengas que aceptar texto libre, porque hay apellidos con apóstrofo y hay direcciones con comillas.

Si tu defensa depende de adivinar todos los caracteres peligrosos, ya perdiste. Si parametrizas, da lo mismo lo que escriban.

Donde sí vale la pena invertir es en el privilegio del usuario de base de datos, que casi nadie toca:

CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'password';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'localhost';

Si bien esto no evita que entren, cambia por completo lo que pueden hacer cuando entran. Sin DROP, sin ALTER y sin GRANT, una inyección exitosa lee datos y no se lleva la base entera ni se deja una puerta para volver. Casi todas las aplicaciones corren con un usuario que puede hacer de todo, porque el día de la instalación era lo más rápido.

”Tengo un WAF”

Un WAF frena los intentos torpes, te avisa que te están probando, y eso tiene valor real. Yo lo tendría.

Lo que no puede es entender tu consulta. Reconoce patrones que alguien ya vio antes, así que lo que te protege es una lista de lo conocido, y las inyecciones ciegas, las que no devuelven nada y se deducen por el tiempo de respuesta, pasan por debajo sin activar ninguna regla.

Tenerlo y no parametrizar es ponerle alarma a una casa con la puerta abierta. La alarma funciona, suena y todo.

Los otros dos tipos, que casi nadie menciona

El ejemplo del login es la inyección clásica, la in-band: inyectas y ves el resultado en la misma pantalla. Es la más fácil de explotar y también la más fácil de detectar.

La ciega es la que preocupa, porque acá no hay nada que mirar: la aplicación no muestra errores, todo se ve normal, la página responde como siempre, y el atacante va deduciendo la respuesta por el comportamiento, si la página tarda cinco segundos la condición era verdadera y si responde al tiro era falsa, y con esa sola señal se reconstruye una base de datos completa, carácter por carácter, mientras en tus logs no aparece ni un solo error SQL porque nunca hubo ninguno.

La fuera de banda es menos común y más ingeniosa: los datos no vuelven por tu aplicación, salen por otro camino, como una consulta DNS hacia un servidor que el atacante controla. Tu monitoreo de la aplicación no ve absolutamente nada porque el tráfico no pasa por ahí.

Las tres se arreglan con lo mismo. Ese es el único consuelo del asunto.

Para probarlo tú mismo

  • sqlmap, para detección automática. Es el estándar.
  • OWASP ZAP, proxy gratuito para revisar tu propia aplicación.
  • Burp Suite, si necesitas algo más completo.

Y esto tengo que decirlo aunque suene a letra chica: úsalas contra sistemas que te pertenecen o para los que tienes autorización por escrito. Correr sqlmap contra un sitio ajeno es un delito, por mucho que tu intención sea avisar.

Por dónde empezar hoy

No por instalar nada.

Por buscar Raw en tu proyecto y revisar cada aparición. Después, por mirar con qué permisos se conecta tu aplicación a la base de datos, que probablemente sea con todos.

Esas dos cosas se hacen en una tarde y son las que de verdad te van a doler si faltan. Todo lo demás viene después.

Artículos relacionados

Volver al blog ↗