FlawOpen

flawopen.com/sql-injection/Java

● CWE-89 · Crítica
Investigación · FlawOpen

SQL Injection en Java: JDBC, JPA y MyBatis

La inyección SQL en Java surge al concatenar entrada en la consulta. Use parámetros y listas permitidas para identificadores, como campos ORDER BY.[1], [2], [3], [7], [4], [6]

💡 Explicación en Lenguaje Sencillo (ELI5)

Imagina una fábrica que bloquea una plantilla metálica antes de recibir las etiquetas de los clientes. JDBC PreparedStatement crea primero la plantilla con una ranura ?, y setLong(1, userId) coloca el valor en ella sin cambiar las instrucciones. Concatenar texto antes de preparar la consulta lo graba en la plantilla. JdbcTemplate y JPQL setParameter también mantienen separados los valores. MyBatis muestra la diferencia: #{status} es un valor enlazado; ${status} es texto SQL insertado. Los campos de ORDER BY son identificadores, no valores: asígnalos mediante una lista permitida o una propiedad Sort.by aprobada.[2], [3], [7], [4], [8]

Conceptos Clave y Términos

PreparedStatement
Una sentencia JDBC compilada a partir de SQL con marcadores ? antes de recibir ningún valor. Los valores entran con setters tipados como setLong(1, id) y nunca alteran la consulta. Usar prepareStatement("..." + id) compila el texto del atacante y no protege nada.[2], [1]
JdbcTemplate query(sql, args)
queryForList(sql, Long.class, status) y update(sql, args...) de Spring enlazan los argumentos finales a los marcadores ? mediante un PreparedStatement. NamedParameterJdbcTemplate hace lo mismo con marcadores :name. Concatenar en el argumento sql se salta todo eso.[3], [2]
JPQL / HQL injection
Las consultas de JPA y Hibernate también se analizan a partir de cadenas. createQuery("... = '" + email + "'") deja que la entrada cambie el JPQL, que Hibernate luego traduce a SQL. Usa :email con setParameter o la Criteria API.[7], [1]
MyBatis #{} vs ${}
En un mapper de MyBatis, #{status} se convierte en un parámetro ? enlazado. ${status} se pega en el SQL como texto. ${} solo es seguro para valores que eliges tú, como una columna de una lista permitida.[4]
Spring Data Sort
Spring Data convierte Sort.by("email") en un ORDER BY sobre propiedades mapeadas y rechaza nombres desconocidos, así que prefiérelo a construir el ORDER BY a mano. Aun así, pasa las claves de la petición por una lista permitida para que nadie ordene por campos que no querías exponer.[8]

Mapeos de marcos

Flujo de Ataque Paso a Paso

Paso 1

El valor de la petición llega al repositorio

Un handler de Spring @GetMapping recibe @RequestParam String id y lo pasa a findEmail(). Como el tipo declarado es String, Spring no convierte el valor a otro tipo; valida explícitamente su formato antes de confiar en él.[9]

Paso 2

La concatenación construye el SQL

"... WHERE id = " + userId produce una única cadena. Con el valor de prueba inofensivo 0 OR 1=1 queda WHERE id = 0 OR 1=1.[1], [2], [6]

Paso 3

El driver compila el texto del atacante

createStatement().executeQuery(sql) envía la cadena entera para su análisis. No hay parámetros, así que la base de datos trata OR 1=1 como parte de la cláusula WHERE.[2], [1]

Paso 4

Vuelven todas las filas

La condición es verdadera para todas las filas, así que la búsqueda devuelve la tabla. Lo mismo ocurre con JPQL: Hibernate traduce fielmente la consulta alterada a SQL.[7], [1]

Código Fuente: Vulnerable vs. Seguro

IMPLEMENTACIÓN VULNERABLE
// UserRepository.java: cada consulta de abajo concatena datos de la petición en el texto de la consulta
public class UserRepository {
    private final DataSource dataSource;
    private final JdbcTemplate jdbc;
    private final EntityManager em;

    public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
        this.dataSource = dataSource;
        this.jdbc = jdbc;
        this.em = em;
    }

    // JDBC: Statement analiza la cadena concatenada, valor incluido
    public String findEmail(String userId) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             Statement st = conn.createStatement();
             ResultSet rs = st.executeQuery("SELECT email FROM users WHERE id = " + userId)) {
            return rs.next() ? rs.getString(1) : null;
        }
    }

    // JdbcTemplate: la concatenación ocurre antes de que Spring vea el SQL
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = '" + status + "'", Long.class);
    }

    // JPA: el propio JPQL se construye con la entrada y luego se traduce a SQL
    public List<User> findByEmail(String email) {
        return em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'", User.class)
                 .getResultList();
    }

    // campo de ORDER BY tomado directamente de la petición
    public List<User> listUsers(String sortField) {
        return em.createQuery("SELECT u FROM User u ORDER BY u." + sortField, User.class)
                 .getResultList();
    }
}
PARCHE SEGURO Y ROBUSTO
// UserRepository.java: los valores son parámetros enlazados, los campos de orden salen de una lista permitida
public class UserRepository {
    private static final Map<String, String> SORTED_QUERIES = Map.of(
            "id", "SELECT u FROM User u ORDER BY u.id",
            "email", "SELECT u FROM User u ORDER BY u.email",
            "created", "SELECT u FROM User u ORDER BY u.createdAt");

    private final DataSource dataSource;
    private final JdbcTemplate jdbc;
    private final EntityManager em;

    public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
        this.dataSource = dataSource;
        this.jdbc = jdbc;
        this.em = em;
    }

    // JDBC: marcador ? compilado primero, valor asignado con un setter tipado
    public String findEmail(long userId) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement("SELECT email FROM users WHERE id = ?")) {
            ps.setLong(1, userId);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getString(1) : null;
            }
        }
    }

    // JdbcTemplate: los argumentos finales se enlazan a los marcadores ?
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = ?", Long.class, status);
    }

    // JPA: parámetro con nombre, valor enlazado con setParameter
    public List<User> findByEmail(String email) {
        return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
                 .setParameter("email", email)
                 .getResultList();
    }

    // búsqueda en la lista permitida; solo fragmentos JPQL constantes llegan a la consulta
    public List<User> listUsers(String sortKey) {
        String jpql = SORTED_QUERIES.getOrDefault(sortKey, SORTED_QUERIES.get("id"));
        return em.createQuery(jpql, User.class)
                 .getResultList();
    }
}

Lista de Verificación de Seguridad para Ingeniería

Fuentes