FlawOpen

flawopen.com/sql-injection/Java

● CWE-89 · Kritisch
Sicherheitsforschung · FlawOpen

Java-SQL-Injection: JDBC, JPA, MyBatis

SQL-Injection in Java entsteht durch verkettete Eingaben im SQL. Nutzen Sie gebundene Parameter und Allowlists für Bezeichner wie ORDER-BY-Felder.[1], [2], [3], [7], [4], [6]

💡 Einfache Erklärung (ELI5)

Stell dir eine Fabrik vor, die eine Metall-Schablone verriegelt, bevor Kundenetiketten eintreffen. JDBC PreparedStatement erstellt zuerst die Schablone mit einer ?-Aufnahme; setLong(1, userId) setzt einen Wert ein, ohne die Anweisungen zu ändern. Vor der Vorbereitung verketteter Text wird dagegen in die Schablone graviert. Auch JdbcTemplate und JPQL setParameter halten Werte getrennt. MyBatis zeigt den Unterschied deutlich: #{status} ist ein gebundener Wert, ${status} eingefügter SQL-Text. Spalten für ORDER BY sind Bezeichner, keine Werte: Ordne sie einer Allowlist oder einer freigegebenen Sort.by-Eigenschaft zu.[2], [3], [7], [4], [8]

Kernkonzepte & Begriffe

PreparedStatement
Eine JDBC-Anweisung, die aus SQL mit ?-Markern kompiliert wird, bevor ein Wert vorliegt. Werte kommen über typisierte Setter wie setLong(1, id) hinein und können die Abfrage nie verändern. prepareStatement("..." + id) kompiliert den Text des Angreifers und schützt nichts.[2], [1]
JdbcTemplate query(sql, args)
Springs queryForList(sql, Long.class, status) und update(sql, args...) binden die nachfolgenden Argumente über ein PreparedStatement an die ?-Marker. NamedParameterJdbcTemplate macht dasselbe mit :name-Markern. Wer ins sql-Argument verkettet, umgeht all das.[3], [2]
JPQL / HQL injection
Auch JPA- und Hibernate-Abfragen werden aus Strings geparst. createQuery("... = '" + email + "'") lässt Eingaben das JPQL verändern, das Hibernate anschließend in SQL übersetzt. Verwenden Sie :email mit setParameter oder die Criteria-API.[7], [1]
MyBatis #{} vs ${}
In einem MyBatis-Mapper wird #{status} zu einem gebundenen ?-Parameter. ${status} wird als Text ins SQL kopiert. ${} ist nur für Werte sicher, die Sie selbst wählen, etwa eine Spalte aus einer Allowlist.[4]
Spring Data Sort
Spring Data macht aus Sort.by("email") ein ORDER BY über gemappte Properties und lehnt unbekannte Namen ab – also lieber das als handgebautes ORDER BY. Request-Schlüssel trotzdem über eine Allowlist führen, damit niemand nach Feldern sortiert, die Sie nicht freigeben wollten.[8]

Framework-Zuordnungen

Schritt-für-Schritt Angriffsablauf

Schritt 1

Der Request-Wert erreicht das Repository

Ein Spring-Handler mit @GetMapping nimmt @RequestParam String id entgegen und reicht es an findEmail() weiter. Da der deklarierte Typ String ist, wandelt Spring den Wert nicht in einen anderen Typ um; sein Format muss vor der Verwendung ausdrücklich geprüft werden.[9]

Schritt 2

Verkettung baut das SQL

"... WHERE id = " + userId erzeugt einen einzigen String. Mit dem harmlosen Testwert 0 OR 1=1 lautet er WHERE id = 0 OR 1=1.[1], [2], [6]

Schritt 3

Der Treiber kompiliert den Text des Angreifers

createStatement().executeQuery(sql) schickt den ganzen String zum Parsen. Es gibt keine Parameter, also behandelt die Datenbank OR 1=1 als Teil der WHERE-Klausel.[2], [1]

Schritt 4

Jede Zeile kommt zurück

Die Bedingung ist für jede Zeile wahr, also liefert die Abfrage die Tabelle. Über JPQL passiert dasselbe: Hibernate übersetzt die veränderte Abfrage getreu in SQL.[7], [1]

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
// UserRepository.java: jede Abfrage unten verkettet Request-Daten in den Abfragetext
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 parst den verketteten String samt Wert
    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: verkettet wird, bevor Spring das SQL überhaupt sieht
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = '" + status + "'", Long.class);
    }

    // JPA: schon das JPQL wird aus Eingaben gebaut und dann in SQL übersetzt
    public List<User> findByEmail(String email) {
        return em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'", User.class)
                 .getResultList();
    }

    // ORDER-BY-Feld direkt aus dem Request
    public List<User> listUsers(String sortField) {
        return em.createQuery("SELECT u FROM User u ORDER BY u." + sortField, User.class)
                 .getResultList();
    }
}
GEHÄRTETER SICHERHEITS-PATCH
// UserRepository.java: Werte sind gebundene Parameter, Sortierfelder kommen aus einer Allowlist
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: ?-Marker zuerst kompiliert, Wert per typisiertem Setter gesetzt
    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: nachfolgende Argumente werden an die ?-Marker gebunden
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = ?", Long.class, status);
    }

    // JPA: benannter Parameter, Wert per setParameter gebunden
    public List<User> findByEmail(String email) {
        return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
                 .setParameter("email", email)
                 .getResultList();
    }

    // Allowlist-Abgleich; nur konstante JPQL-Fragmente gelangen in die Abfrage
    public List<User> listUsers(String sortKey) {
        String jpql = SORTED_QUERIES.getOrDefault(sortKey, SORTED_QUERIES.get("id"));
        return em.createQuery(jpql, User.class)
                 .getResultList();
    }
}

Checkliste für Engineering & Systemsicherheit

Quellen