Loading...

Seguridad · 11 min de lectura

Qué es la inyección SQL: cómo funciona y cómo detenerla

La inyección SQL es una vulnerabilidad en la que la entrada del usuario se concatena en una consulta a la base de datos y la base de datos ejecuta parte de ella como SQL. Permite a un atacante leer, cambiar o borrar datos, y a veces tomar el control del servidor. La solución es enviar la consulta y los valores por separado con consultas parametrizadas; el privilegio mínimo, la validación y un WAF limitan el daño cuando algo se escapa.

Actualizado

Qué es la inyección SQL: cómo funciona y cómo detenerla

Qué es la inyección SQL

La inyección SQL (SQLi) es una vulnerabilidad en la que un texto proporcionado por un usuario se pega en una consulta a la base de datos y la base de datos termina ejecutándolo como SQL. La aplicación quería enviar un valor, como una dirección de correo o un ID de producto; el atacante envía en su lugar un fragmento de lenguaje de consulta, y el significado de la consulta cambia.

La causa raíz es siempre la misma: código que construye una consulta por concatenación de cadenas, mezclando dos tipos de datos en una sola cadena. El texto de la consulta son instrucciones que escribiste tú. El valor son datos que escribió otra persona. En cuanto se pegan juntos, la base de datos no tiene forma de saber dónde terminan tus instrucciones y dónde empieza la entrada del visitante.

Por eso la inyección SQL no es específica de MySQL, PostgreSQL, SQL Server o SQLite, ni tampoco de PHP. Cualquier lenguaje, cualquier driver y cualquier base de datos es vulnerable en el momento en que una consulta se construye a partir de cadenas no fiables. Está catalogada como CWE-89, y la inyección ha aparecido en todas las ediciones del OWASP Top 10. El grupo de búsquedas a su alrededor, "ataque SQL", "vulnerabilidad SQL", "ataque de inyección SQL", describen todas este mismo error.

La buena noticia es que la solución es igual de uniforme, tiene décadas de antigüedad y viene integrada en todos los drivers de base de datos en uso hoy: enviar la consulta y los valores por separado.

Cómo funciona: el ejemplo de manual

Toma una comprobación de login escrita como la escribían incontables tutoriales. El nombre de usuario y la contraseña vienen de un formulario y se meten directamente en la cadena SQL.

Con una entrada normal, la consulta hace lo que debería. Ahora imagina que un visitante escribe ' OR '1'='1 en el campo de la contraseña. La comilla cierra el literal de cadena que abrió el desarrollador, y el resto se convierte en parte de la cláusula WHERE. Como '1'='1' siempre es verdadero y AND liga más fuerte que OR, la condición coincide con todas las filas, y el código deja entrar al visitante como el primer usuario de la tabla, que suele ser el administrador.

Esa sola comilla es todo el concepto. Todo lo demás en esta guía, los distintos tipos de ataque, el impacto y las defensas, se deriva del hecho de que la base de datos recibió una sola cadena y analizó el texto del atacante como código.

(Guardar contraseñas en texto plano y compararlas en SQL es un segundo fallo en este ejemplo. El código real recupera al usuario por nombre y comprueba la contraseña con password_verify() contra un hash. Se mantiene aquí porque es la forma más corta de mostrar la inyección.)

Vulnerable: la entrada del usuario concatenada en la consulta (PHP)

<?php
// DO NOT DO THIS
$user = $_POST['username'];
$pass = $_POST['password'];

$sql = "SELECT id FROM users
        WHERE username = '$user' AND password = '$pass'";
$row = $pdo->query($sql)->fetch();

// With password  ' OR '1'='1  the database receives:
// SELECT id FROM users
// WHERE username = 'admin' AND password = '' OR '1'='1'
// ...which is true for every row.

La solución: consultas parametrizadas, en PHP y Python

Una consulta parametrizada (también llamada prepared statement o parámetros vinculados) envía el texto SQL con marcadores, ? o :nombre o %s según el driver, y envía los valores por separado. La base de datos analiza y planifica la consulta primero, y luego introduce los valores como datos. Una comilla dentro de un valor es solo un carácter en una cadena; nunca puede cerrar un literal ni añadir una cláusula, porque el análisis ya terminó.

Esto no es un paso de limpieza que pueda fallar en algún caso. Elimina por completo el mecanismo, por eso encabeza todas las listas de prevención.

En PHP, usa PDO o mysqli con marcadores de posición. Con PDO, fija el juego de caracteres en el DSN y desactiva los prepares emulados para que el driver envíe prepared statements reales del lado del servidor, y activa las excepciones para que los fallos no se ignoren en silencio. En Python, todos los drivers DB-API (sqlite3, psycopg, mysqlclient, PyMySQL) reciben los valores como un argumento separado de execute(). La trampa en Python es formatear tú mismo la cadena con un f-string o % antes de llamar a execute(): el resultado parece parametrizado pero es concatenación.

La misma regla se cumple en cualquier otra pila tecnológica: PreparedStatement en Java, SqlParameter en .NET, marcadores $1 en node-postgres, ? en database/sql de Go.

Seguro: marcadores de posición, valores enviados por separado (PHP PDO y Python)

<?php
$pdo = new PDO('mysql:host=db;dbname=shop;charset=utf8mb4', $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$_POST['username']]);
$row = $stmt->fetch();
$ok  = $row && password_verify($_POST['password'], $row['password_hash']);

# Python (psycopg 3, PostgreSQL)
cur.execute(
    "SELECT id, password_hash FROM users WHERE username = %s",
    (username,),            # values go here, never into the string
)

# WRONG, still injectable: the string is built before execute() sees it
cur.execute(f"SELECT id FROM users WHERE username = '{username}'")

Tipos de inyección SQL

Los artículos de seguridad clasifican la inyección SQL por cómo recupera los datos el atacante. La vulnerabilidad es la misma en todos los casos; lo que cambia es lo que revela la aplicación.

In-band, basada en UNION. El resultado de la consulta inyectada vuelve en la propia página. Añadiendo un UNION SELECT, el atacante suma filas de otra tabla, digamos la de usuarios, a la lista de productos que la página ya mostraba. Es el tipo más rápido de explotar y el más fácil de detectar al probar.

In-band, basada en errores. La aplicación imprime los mensajes de error de la base de datos. El atacante provoca errores cuyo texto contiene el dato que quiere, como un nombre de tabla o un valor. Mostrar errores SQL en crudo a los visitantes convierte un fallo ciego en uno legible, lo que es una buena razón para registrar los errores en el servidor y mostrar a los usuarios una página genérica.

Ciega, basada en booleanos. La página no muestra datos ni errores, pero se comporta de forma distinta cuando una condición es verdadera o falsa: un producto aparece o no, una página responde 200 o 404. Haciendo preguntas de sí o no una por una, un atacante lee los datos poco a poco. Es lento, pero hay herramientas que lo automatizan.

Ciega, basada en tiempo. Incluso el contenido de la página es idéntico, así que el atacante hace que la base de datos espere cuando una condición es verdadera y mide el tiempo de respuesta. Si todas las respuestas parecen iguales, el tiempo sigue siendo un canal.

Fuera de banda. El atacante hace que el propio servidor de base de datos contacte con un sistema que controla, típicamente mediante una consulta DNS o una petición HTTP disparada por una función de la base de datos. Depende de que ciertas funciones de la base de datos y la salida a la red estén disponibles, lo que es una razón más para que un servidor de base de datos no pueda abrir conexiones salientes que no necesita.

De segundo orden (almacenada). La entrada se guarda de forma segura la primera vez, correctamente parametrizada, y luego se lee más adelante y se concatena en otra consulta distinta, a menudo en un informe de administración o una tarea en segundo plano. Los desarrolladores confían en los datos que vinieron de su propia base de datos; esa confianza es el fallo. La defensa es la misma que en todas partes: parametriza todas las consultas, incluidas las construidas con valores que tú mismo guardaste.

Lo que consigue un atacante en realidad

El impacto está limitado por lo que se le permite hacer a la cuenta de base de datos que usa la aplicación, por eso el privilegio mínimo importa tanto más adelante en esta guía.

Leer datos. El resultado más común: registros de clientes, direcciones de correo, hashes de contraseñas, pedidos, claves de API guardadas en tablas de configuración. Cualquier tabla a la que esa cuenta pueda hacer SELECT es legible, no solo la que toca la consulta vulnerable.

Saltarse la autenticación. Como en el ejemplo del login, una condición que debería ser específica se vuelve siempre verdadera.

Cambiar o destruir datos. Si la cuenta puede hacer UPDATE, INSERT o DELETE, también puede hacerlo el atacante: cambiar precios, crear usuarios administradores, vaciar tablas. Algunas combinaciones de driver y base de datos permiten varias instrucciones en una sola llamada, lo que amplía esto todavía más.

Llegar al servidor. Con privilegios potentes, algunas bases de datos pueden leer o escribir archivos en el host o ejecutar comandos del sistema operativo. En una cuenta de base de datos con derechos administrativos, una inyección SQL puede convertirse en un compromiso completo del servidor.

Esto no es teórico. La inyección SQL fue el punto de entrada en la brecha de 2008 de Heartland Payment Systems, donde se robaron datos de bastante más de 100 millones de tarjetas; en la brecha de 2015 de TalkTalk en el Reino Unido, que acabó en una multa regulatoria; y en la explotación masiva de 2023 de MOVEit Transfer (CVE-2023-34362), donde un único fallo de inyección en un producto de transferencia de archivos se usó contra miles de organizaciones. Fallo viejo, titulares actuales.

Prevención, por orden de importancia

1. Consultas parametrizadas en todas partes. Cada consulta, cada valor, incluidos los valores de tu propia base de datos, cookies, cabeceras y servicios internos. Esto por sí solo cierra la vulnerabilidad. Los demás pasos limitan el daño cuando alguien, en algún lugar, se olvida.

2. Usa bien tu ORM o query builder, y conoce sus vías de escape. Eloquent, Doctrine, Django ORM, SQLAlchemy, Hibernate y Entity Framework parametrizan las consultas normales por ti. No protegen los fragmentos raw: whereRaw, DB::raw, selectRaw y orderByRaw en Laravel, .extra() y .raw() en Django, text() en SQLAlchemy, FromSqlRaw en EF Core. Todos ellos aceptan bindings; úsalos. Buscar en el código esos nombres de método es la revisión más rápida que puedes hacer.

3. Lista de permitidos para lo que no puede ser un parámetro. Los marcadores de posición guardan valores, no identificadores. Un nombre de tabla, una columna en ORDER BY o las palabras ASC/DESC no se pueden vincular, así que un parámetro de "ordenar por" tiene que mapearse a una lista fija de nombres de columna en el código. Nunca lo dejes pasar directamente.

4. Privilegio mínimo para el usuario de base de datos. La aplicación web debería conectarse como un usuario que puede hacer SELECT, INSERT, UPDATE y DELETE en su propio esquema y nada más: sin DROP, sin GRANT, sin acceso a archivos, sin otras bases de datos, y nunca la cuenta root o sa. Las migraciones se ejecutan con un usuario distinto y más fuerte. Si una inyección se escapa, esto decide si filtra un esquema o se adueña del servidor.

5. Validación de entrada como defensa en profundidad. Un ID de pedido debería ser un entero, una fecha debería poder parsearse como fecha, un código de país son dos letras. Validar tipos y formatos en el borde de tu aplicación rechaza mucha entrada maliciosa pronto y detecta errores. No es la solución: un campo de nombre tiene que aceptar O'Brien, y un campo de comentario acepta casi cualquier cosa.

6. No filtres errores. Registra los errores de base de datos en el servidor con la consulta y el contexto; muestra al visitante una página de error genérica. En PHP eso significa display_errors=Off en producción.

Fragmentos raw con bindings, un orden con lista de permitidos y un usuario de privilegio mínimo

// Laravel: raw expressions must carry their own bindings
$orders = DB::table('orders')
    ->whereRaw('total > ? AND status = ?', [$min, $status])
    ->get();

// ORDER BY cannot be bound: map input to known columns
$sortable = ['created_at', 'total', 'status'];
$column   = in_array($request->sort, $sortable, true) ? $request->sort : 'created_at';
$dir      = $request->dir === 'asc' ? 'asc' : 'desc';
$orders   = Order::orderBy($column, $dir)->paginate(50);

-- MySQL: the app user gets data access to its own schema only
CREATE USER 'shop_app'@'10.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'10.0.%';

Por qué escapar no es suficiente

Antes de que los prepared statements fueran comunes, el consejo era escapar las comillas: addslashes(), luego mysql_real_escape_string(), luego mysqli::real_escape_string(). El escapado sigue presente en mucho código, y falla de formas predecibles.

Solo protege contextos entre comillas. WHERE id = $id no tiene comillas alrededor del valor, así que no hay nada que escapar, y una entrada como 1 OR 1=1 pasa sin tocar. Lo mismo ocurre con LIMIT, ORDER BY y cualquier cosa numérica.

Depende del juego de caracteres. Las funciones de escapado tienen que coincidir con la codificación de la conexión. Los desajustes entre el juego de caracteres del cliente y del servidor han permitido que secuencias multibyte se traguen la barra invertida del escapado; addslashes() nunca conoció la codificación en absoluto.

Hay que recordarlo todas y cada una de las veces. Una sola llamada olvidada en una consulta, o un valor escapado para HTML en lugar de para SQL, y el agujero vuelve. Las consultas parametrizadas hacen que el camino seguro sea el camino por defecto.

El mismo razonamiento se aplica a las listas negras y a las funciones "saneadoras" que eliminan palabras como SELECT o --. Los atacantes han pasado veinte años encontrando codificaciones, comentarios y variaciones de mayúsculas que se escapan a esos filtros. Trata cualquier filtro hecho a mano como una comodidad, nunca como la protección.

Probar tu propia aplicación de forma segura

Empieza por el código, no por el ataque. La mayoría de la inyección SQL es visible con una búsqueda en el código: consultas ensambladas con ., +, interpolación de cadenas o sprintf, y métodos raw del ORM llamados con una variable. Herramientas de análisis estático como Semgrep y CodeQL incluyen reglas exactamente para este patrón y pueden ejecutarse en CI en cada pull request.

Luego escribe tests que pasen valores que parezcan hostiles pero inofensivos, una sola comilla, un nombre como O'Brien, una cadena muy larga, a través de tus formularios y APIs, y comprueba que la aplicación devuelve un resultado normal o un error de validación, nunca un 500 ni un mensaje de error de base de datos. Esos tests se quedan en la suite y protegen contra regresiones.

Para pruebas dinámicas, escáneres de código abierto como OWASP ZAP y sqlmap sondean aplicaciones en ejecución buscando parámetros inyectables. sqlmap en particular es una herramienta de explotación: extrae datos cuando encuentra un agujero. Ejecútalo solo contra sistemas que posees o para los que tienes permiso por escrito, preferiblemente una copia de staging con datos falsos, porque sus sondas escriben en los logs, pueden cambiar datos y pueden disparar tu monitorización. Escanear el sitio de otra persona sin permiso es ilegal en la mayoría de los países, cualquiera que sea la intención.

Si pruebas a través de tu CDN, espera que el WAF bloquee muchas de las sondas. Eso es el WAF funcionando, pero también oculta el comportamiento real de la aplicación, así que prueba la aplicación en sí en staging y trata el WAF como una capa separada.

Encuentra SQL concatenado en un código antes de que lo haga otro
# PHP: query calls that mention a variable (review each hit)
grep -rnE '(query|exec|prepare)\(.*\$[A-Za-z_]' --include='*.php' app/

# Laravel / Eloquent raw fragments: check each one has bindings
grep -rnE '(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw)\(' app/

# Python: f-strings or % formatting inside execute()
grep -rnE "execute\(\s*f['\"]|execute\(.*['\"]\s*%\s" --include='*.py' .

Dónde encaja un WAF: una capa, no la solución

Un firewall de aplicaciones web inspecciona cada petición antes de que llegue a tu aplicación y bloquea las que parecen ataques. La inyección SQL deja formas reconocibles en las cadenas de consulta y en los cuerpos de los formularios, así que esto es una de las cosas en las que un WAF es bueno: detiene a los escáneres automatizados que sondean todos los sitios de internet, y te compra tiempo cuando se divulga un plugin vulnerable antes de que puedas parchearlo.

Sigue siendo comparación de patrones contra una vulnerabilidad que vive en tu código. Un atacante dirigido da forma al payload para evitar los patrones, y algunos puntos de inyección, como campos JSON con una codificación poco habitual, valores que llegan a una consulta a través de una tarea en segundo plano, o la inyección de segundo orden, nunca parecen sospechosos en una petición. La guía del OWASP Top 10 mapea lo que un WAF puede y no puede cubrir. Las consultas parametrizadas siguen siendo la solución.

El coste operativo son los falsos positivos. Cualquier regla lo bastante estricta como para atrapar la inyección a veces bloqueará a una persona: un editor guardando un artículo con un ejemplo de código, un formulario de soporte donde alguien pega un mensaje de error que contiene SQL. La práctica habitual es ejecutar las reglas nuevas primero en modo de detección (solo registro), leer qué se habría bloqueado, y luego hacer cumplir, con excepciones estrechas donde un campo legítimamente lleva texto parecido a SQL.

En CDN.com.tr el WAF es ModSecurity con el OWASP Core Rule Set, activado por cuenta desde la página de reglas de entrega. En ese conjunto de reglas, la familia 942 es la de las reglas de inyección SQL. Un visitante bloqueado recibe una página 403 de marca propia con un ID de referencia, que es el identificador de la petición en el registro de auditoría, así que puedes ver exactamente qué regla coincidió y en qué campo. La página de logs del WAF en el panel lista los eventos bloqueados con la categoría de ataque, el país, la IP y la regla, exporta a CSV o XLSX, y cdnctl waf logs muestra lo mismo desde la línea de comandos. Cuando un campo legítimo activa una regla, la solución debería ser estrecha: esa regla, en ese campo, en esa ruta, en lugar de desactivar la protección para todo el sitio. Las excepciones por rutas individuales todavía no están en el panel, así que envía el ID de referencia de la petición a soporte. Para los formularios de login, el rate limiting por ruta está en la misma página y frena el tráfico de fuerza bruta que a menudo acompaña a los escaneos de inyección.

Preguntas frecuentes sobre inyección SQL

¿Sigue siendo un problema la inyección SQL en 2026?

Sí. Los frameworks modernos hacen que el camino seguro sea el predeterminado, pero las consultas raw, el código heredado, los plugins y las herramientas internas rápidas siguen concatenando cadenas, y se siguen divulgando y explotando a gran escala nuevos fallos de inyección en productos muy usados. La inyección ha aparecido en todas las ediciones del OWASP Top 10.

¿Los prepared statements previenen toda la inyección SQL?

Previenen la inyección a través de cada valor que vincules. No pueden vincular identificadores como nombres de tabla o columna ni la dirección de orden, y no ayudan si primero construyes una cadena y luego "preparas" el resultado. Pon en lista de permitidos los identificadores y nunca formatees la entrada dentro del texto SQL.

¿Usar un ORM me hace estar a salvo de la inyección SQL?

En su mayoría, para las consultas normales. Todos los ORM tienen métodos raw, como whereRaw, DB::raw, .raw(), text() o FromSqlRaw, que pasan el SQL sin tocarlo. Úsalos con bindings, y revisa cada llamada.

¿Es suficiente la validación de entrada para detener la inyección SQL?

No. La validación es una segunda línea útil: un ID debería ser numérico y una fecha debería poder parsearse. Pero muchos campos deben aceptar comillas y texto libre, así que la validación no puede ser la protección. Las consultas parametrizadas sí lo son.

¿Puede un WAF detener completamente la inyección SQL?

Detiene la mayoría de los intentos automatizados y eleva el esfuerzo necesario para los dirigidos, pero compara patrones en las peticiones y un atacante decidido puede adaptar un payload para evitarlos. La inyección de segundo orden nunca parece sospechosa en una petición. Usa un WAF como profundidad, con las consultas parametrizadas como la solución.

¿Es legal probar un sitio web para detectar inyección SQL con sqlmap?

Solo en sistemas que poseas o para los que tengas permiso explícito por escrito. Escanear el sitio de otra persona es acceso no autorizado en la mayoría de las jurisdicciones. Prueba tu propio entorno de staging con datos falsos; si encuentras un fallo en el producto de otra persona, infórmalo a través de su proceso de divulgación.

¿Cuál es la diferencia entre la inyección SQL y XSS?

La inyección SQL hace que tu base de datos ejecute SQL escrito por el atacante. El cross-site scripting hace que el navegador de un visitante ejecute JavaScript escrito por el atacante en el contexto de tu sitio. Las dos son inyección, con la misma causa raíz de mezclar datos y código, y cada una necesita su propia solución: consultas parametrizadas para SQL, codificación de salida sensible al contexto para HTML. Consulta ¿Qué es XSS?.