Command Palette

Search for a command to run...

ES·EN

Nivel 2 · 25 min

Inyección SQL: Anatomía y Defensas

La inyección SQL está en el OWASP Top 10 desde que la lista se publicó por primera vez en 2003. Persiste porque el patrón inseguro (concatenación de strings) es el default, y el patrón seguro (parameterized statements) requiere que el desarrollador conozca la diferencia. Una sola SQLi en un endpoint de autenticación puede filtrar toda la tabla de usuarios.

Anatomía de una SQLi Clásica

Considerá: String sql = "SELECT * FROM users WHERE email=''" + email + "'' AND password=''" + pwd + "''". Un atacante envía email=admin@x.com''-- y cualquier password. La query queda SELECT * FROM users WHERE email=''admin@x.com''-- AND password=''cualquiera'' — el -- comenta el resto, y el atacante entra como admin. Variantes: extracción basada en UNION (UNION SELECT username, password FROM users), queries apiladas (; DROP TABLE...), e inyección de segundo orden (el payload se guarda en la base y se dispara cuando se lee de forma insegura más tarde).

Parameterized Statements: La Solución Real

PreparedStatement separa la estructura de la query del dato. La base recibe ''SELECT * FROM users WHERE email=? AND password=?'' una sola vez, planifica una sola vez, y cualquier valor de ? se trata estrictamente como dato — no se parsea como SQL. No hay encoding que se pueda equivocar, no hay función de escape que se pueda saltear; el parser nunca ve el input del usuario. La excepción son los identificadores (nombres de tabla/columna) que no se pueden parametrizar — esos necesitan un allowlist. Los frameworks modernos (JPA/Hibernate, Spring Data, sqlc, Prisma) generan queries parametrizadas por defecto; el peligro es cuando los devs bajan a entityManager.createNativeQuery(''SELECT ... '' + dynamicFilter) para un ''reporte custom rápido''. ORM no es defensa — concatenación dentro de un ORM es igual de explotable.

SQLi Ciega y Defensa en Profundidad

Las apps modernas suelen capturar excepciones de la base y devuelven un 500 genérico. Esto mata la SQLi basada en errores pero habilita la SQLi ciega: basada en booleanos (el atacante manda id=1 AND substring(password,1,1)=''a'' y observa si la respuesta cambia) o basada en tiempo (id=1; SELECT pg_sleep(5) — si la respuesta tarda 5 segundos extra, la inyección funciona). sqlmap automatiza la extracción a ~1 carácter/segundo. Defensas más allá de la parametrización: (1) cuenta de DB con privilegios mínimos — el usuario de la aplicación debe tener GRANT SELECT/INSERT/UPDATE en tablas específicas, nunca DROP, ALTER, ni acceso a catálogos del sistema; (2) reglas de WAF como respaldo, no como defensa primaria; (3) validación de input como defensa en profundidad (un ID numérico se rechaza si no es numérico) pero nunca como única defensa.

Puntos clave

  • Los parameterized statements (PreparedStatement, $1, named bindings) son la única defensa confiable — el escape es frágil, la validación es incompleta.
  • Los ORMs no protegen contra concatenación dentro de createNativeQuery / raw() / $queryRaw — eso es igual de explotable que JDBC plano.
  • Corré la aplicación como un usuario de DB con bajos privilegios. Si el rol de la app no tiene DROP, hasta una SQLi exitosa no puede destruir el schema.

Code example

// VULNERABLE — concatenación de strings
String sql = "SELECT * FROM users WHERE email='" + email + "'";
ResultSet rs = stmt.executeQuery(sql);

// CORREGIDO — PreparedStatement con placeholders
String sql = "SELECT * FROM users WHERE email = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
  ps.setString(1, email);
  try (ResultSet rs = ps.executeQuery()) {
    // ...
  }
}

// VULNERABLE — el ORM no es un escudo mágico
String jpql = "SELECT u FROM User u WHERE u.role = '" + role + "'";
em.createQuery(jpql, User.class).getResultList();

// CORREGIDO — parámetro nombrado, ORM-aware
em.createQuery("SELECT u FROM User u WHERE u.role = :role", User.class)
  .setParameter("role", role)
  .getResultList();