La inyección SQL (SQLi) aparece cuando una aplicación arma una consulta SQL pegando el input del usuario dentro del string. La base de datos no puede distinguir qué parte escribió el desarrollador y qué parte mandó el atacante, así que el atacante termina reescribiendo la consulta. Es una de las vulnerabilidades web más viejas y sigue estando en todos lados: el OWASP Top 10:2025 mantiene Injection en la lista (A05) y registra más de 14.000 CVEs vinculados solo a inyección SQL.
La buena noticia: es de las pocas clases de vulnerabilidad que tiene una solución casi perfecta. En esta guía vas a ver cómo funciona, los tipos principales, ejemplos de inyección SQL con código vulnerable y corregido en cinco stacks, las trampas de los ORMs que todavía agarran a equipos con experiencia, y cómo encontrarla en tu propio código.
Cómo funciona una inyección SQL
Imaginá un endpoint de login que arma la consulta así:
query = "SELECT * FROM users WHERE email = '" + email + "' AND password_hash = '" + hash + "'"Un usuario normal escribe [email protected] y todo funciona. Un atacante escribe esto en el campo email:
' OR '1'='1' -- La consulta que llega a la base queda así:
SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password_hash = '...'La comilla cierra el string antes de tiempo, OR '1'='1' hace que la condición sea siempre verdadera y -- comenta el chequeo de password. La base devuelve todos los usuarios y la app loguea al atacante como el primero, que muchas veces es un admin.
La causa de fondo es siempre la misma: los datos se tratan como código. Escapar comillas a mano, bloquear palabras como UNION o confiar en un WAF solo hacen el ataque más difícil. Lo que lo resuelve de verdad es separar datos de código.
Tipos de inyección SQL
| Tipo | Cómo obtiene datos el atacante | Idea del payload |
|---|---|---|
| In-band: UNION-based | Agrega un segundo SELECT cuyas filas aparecen en la respuesta normal | ' UNION SELECT email, password_hash FROM users -- |
| In-band: error-based | Fuerza un error de la base cuyo mensaje filtra datos | Errores de conversión de tipo que imprimen un valor |
| Blind: boolean-based | Hace preguntas de sí/no y mira cómo cambia la página | ' AND 1=1 -- vs ' AND 1=2 -- |
| Blind: time-based | Hace que la base espere cuando una condición es verdadera | SLEEP(5) en MySQL, pg_sleep(5) en PostgreSQL |
| Out-of-band | Hace que la base mande datos a un servidor externo (DNS o HTTP) | Funciones de red específicas de cada motor |
| Second-order | El payload se guarda bien y después se usa mal | Un username como admin' -- reutilizado en otra consulta |
In-band (UNION y error-based)
Es la más fácil de explotar porque los resultados vuelven por el mismo canal que la respuesta normal. Con UNION, el atacante primero averigua cuántas columnas devuelve la consulta original y después agrega UNION SELECT para leer cualquier tabla a la que tenga acceso el usuario de la base. La variante error-based funciona cuando la app muestra errores crudos: el atacante arma un input que fuerza un mensaje de error con el dato que busca adentro.
Blind (boolean y time-based)
Aunque la app no muestre errores ni resultados, el atacante puede sacar datos bit por bit. En la boolean-based compara respuestas: si AND 1=1 devuelve la página del producto y AND 1=2 devuelve "no encontrado", puede preguntar cosas como "¿el primer carácter del hash del admin es mayor que m?". En la time-based usa demoras: si la respuesta tarda cinco segundos, la respuesta es sí. A mano es lento, automatizado es trivial. Por eso "no mostramos errores" no es una defensa.
Second-order
Es la que se escapa en los code reviews. El input se guarda correctamente, por ejemplo con un INSERT parametrizado. Después otra parte del código (un job en background, un reporte de admin, el flujo de reset de password) lee ese valor de la base y lo concatena en otra consulta, confiando porque "viene de nuestra base". Todo valor que originalmente vino de un usuario es no confiable, lo leas de donde lo leas.
Ejemplos de inyección SQL: código vulnerable vs corregido
La solución es la misma en todos lados: consultas parametrizadas (prepared statements o bind variables). La estructura de la consulta viaja primero a la base y los valores viajan aparte, así que nunca pueden cambiar la consulta.
Node.js (pg / mysql2)
// Vulnerable
const { rows } = await db.query(
`SELECT * FROM orders WHERE user_id = ${req.params.id}`
);
// Corregido (pg usa $1, $2... ; mysql2 usa ?)
const { rows } = await db.query(
"SELECT * FROM orders WHERE user_id = $1",
[req.params.id]
);Python (psycopg / sqlite3)
# Vulnerable: f-strings y formateo con % arman el string SQL
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
# Corregido: los valores van como segundo argumento
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # psycopg
cur.execute("SELECT * FROM users WHERE email = ?", (email,)) # sqlite3Ojo con la trampa sutil: cur.execute("... = %s" % email) parece parametrizado pero es formateo de strings común. El valor tiene que pasar como argumento separado.
Ruby on Rails (ActiveRecord)
# Vulnerable: interpolación dentro de un fragmento SQL
User.where("email = '#{params[:email]}'")
# Corregido: condiciones con hash o placeholders
User.where(email: params[:email])
User.where("email = ?", params[:email])
# Los identificadores no se pueden bindear: allowlist
SORTABLE = %w[created_at name].freeze
column = SORTABLE.include?(params[:sort]) ? params[:sort] : "created_at"
User.order(column)Las versiones recientes de Rails rechazan muchos strings de SQL crudo en order, pero find_by_sql, pluck con Arel.sql, joins con strings y exists? con strings siguen siendo fáciles de usar mal.
PHP (PDO)
// Vulnerable
$result = $pdo->query("SELECT * FROM users WHERE id = " . $_GET['id']);
// Corregido
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);
$user = $stmt->fetch();mysqli_real_escape_string no reemplaza a los parámetros: no hace nada en contextos numéricos como WHERE id = 1 OR 1=1, donde no hay comillas para escapar.
Java (JDBC y JPA)
// Vulnerable
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
"SELECT * FROM accounts WHERE owner = '" + owner + "'");
// Corregido
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM accounts WHERE owner = ?");
ps.setString(1, owner);
ResultSet rs = ps.executeQuery();
// JPA: JPQL también es inyectable si concatenás
List<Account> list = em.createQuery(
"SELECT a FROM Account a WHERE a.owner = :owner", Account.class)
.setParameter("owner", owner)
.getResultList();Trampas de los ORMs: cuando "usamos un ORM" deja de protegerte
Los ORMs parametrizan los valores cuando usás su query builder. El problema es que todos traen una salida a SQL crudo, y ahí vuelve la inyección:
- Prisma:
$queryRaw`... ${id}`(tagged template) está parametrizado.$queryRawUnsafe("... " + id)no. - Sequelize / Knex:
sequelize.query()yknex.raw()son seguros solo conreplacements,bindo bindings?, nunca con template strings. - Django:
Model.objects.raw(),.extra()ycursor.execute()necesitan la lista de params.raw(f"...")es vulnerable. - SQLAlchemy:
text("... WHERE id = :id")con params bindeados está bien;text(f"... {id}")no. - ActiveRecord: condiciones en string dentro de
where,order,group,having,joinsyfind_by_sql.
Dos trampas más. Primero, los identificadores no se pueden parametrizar: nombres de tablas, columnas y la dirección de orden (ASC/DESC) se validan contra una allowlist. Segundo, los stored procedures no son seguros por sí solos: un procedure que arma SQL dinámico concatenando adentro es igual de inyectable.
Inyección NoSQL, en corto
Pasarte a MongoDB no hace desaparecer la inyección, le cambia la forma. El caso clásico en Node.js es la inyección de operadores:
// Vulnerable: req.body.password puede ser un objeto como {"$ne": null}
const user = await User.findOne({
email: req.body.email,
password: req.body.password
});
// Corregido: validar tipos antes de consultar
if (typeof req.body.email !== "string" || typeof req.body.password !== "string") {
return res.status(400).end();
}Si el body es JSON, el atacante puede mandar {"email": "[email protected]", "password": {"$ne": null}} y matchear cualquier password. Validá el input con un schema (Zod, Joi, JSON Schema), casteá los valores al tipo esperado y descartá las keys que empiezan con $. Evitá $where y todo lo que evalúe JavaScript en el servidor.
Defensa en profundidad
- Privilegios mínimos: el usuario de base de la app no debería poder borrar tablas, leer otros schemas ni correr funciones del sistema.
- Errores genéricos: logueá los errores de base en el servidor y devolvé un mensaje genérico. Eso mata la extracción error-based.
- Validación de input: rechazá temprano un
idque no sea numérico. No reemplaza la parametrización, pero achica la superficie de ataque. - El WAF es un cinturón, no la solución: bloquea payloads comunes, pero los trucos de encoding y las técnicas blind pasan. Arreglá el código.
Cómo encontrar inyección SQL en tu código
- Buscá los patrones obvios. Keywords SQL al lado de
+, template literals, f-strings, formateo con%o#{}, y métodos crudos:queryRawUnsafe,.raw(,find_by_sql,createStatement,executeQuery,.extra(. - Revisá el flujo de datos, no solo líneas sueltas. El código peligroso suele ser un helper que arma el WHERE a tres archivos del controller. Un secure code review sigue el input desde el request hasta la consulta.
- Corré SAST. El análisis estático sigue el input contaminado hasta la base atravesando archivos y detecta lo que un humano pasa por alto. Si no tenés clara la diferencia, leé SAST vs DAST.
- Agregá tests de seguridad. Para cada endpoint que toca la base, un test que mande
' OR '1'='1,1 OR 1=1y un payload de operador JSON, y verifique que la respuesta sea 4xx o vacía, nunca datos de más. - Probá la app corriendo. Las herramientas DAST y sqlmap confirman si es explotable, pero usalas solo contra sistemas propios o con autorización explícita.
Si querés automatizar los tres primeros pasos, escaneá tu repositorio en busca de vulnerabilidades. Con Nurbak conectás GitHub y escaneás un repo; el modelo de IA propio y self-hosted de Nurbak analiza el código (el análisis no manda tu código a OpenAI ni a Anthropic) y reporta problemas explotables como inyección SQL con el archivo y la línea exactos, más una explicación en lenguaje simple. También puede abrir un Pull Request con el fix y un test de regresión de seguridad. El escaneo gratis te muestra completos los 3 hallazgos más importantes.
Checklist para prevenir inyección SQL
- Toda consulta usa parámetros o el query builder del ORM. Cero concatenación o interpolación en SQL.
- Los métodos crudos (
$queryRawUnsafe,raw(),find_by_sql,text()) están revisados y usan params bindeados. - Nombres de columnas, tablas y dirección de orden salen de una allowlist.
- Los valores leídos de tu propia base se tratan como no confiables al reutilizarlos (second-order).
- Las consultas NoSQL validan tipos y rechazan operadores
$que vengan del usuario. - El usuario de base tiene privilegios mínimos; producción no se conecta como superusuario.
- Los errores crudos de la base nunca llegan al cliente.
- El CI corre SAST en cada pull request y hay tests de regresión con payloads de inyección.
- Las dependencias (drivers, ORMs) están actualizadas y chequeadas contra CVEs conocidos.
La inyección SQL es solo una pieza. Mirá el OWASP Top 10 2025 para el resto de las categorías, qué es DevSecOps para meter estos controles en tu pipeline y qué es el pentesting para ver cómo un atacante encadena una SQLi con otras fallas. Cuando quieras, encontrá las vulnerabilidades de tu código antes que otro.
