SQL-Injection und Prepared Statement in Java

1. Übersicht

SQL-Injection ist eine der Top-Sicherheitslücken auf der OWASP-Liste und entsteht durch das manuelle Verketten von SQL-Strings mit Benutzereingaben. Dieser Artikel erklärt das Angriffsverfahren, zeigt, warum PreparedStatement es blockiert, und leitet Sie an, es bei JDBC, JPA/Hibernate und MyBatis anzuwenden. Danach können Sie sichere Abfrage-Code schreiben und Tests zur frühen Erkennung von Sicherheitslücken verfassen.

2. Wie funktioniert SQL-Injection

Der Fehler tritt auf, wenn Sie Eingabewerte direkt in einen SQL-String verketten, anstatt sie zu parametrisieren.

// SO SOLLTEN SIE ES NICHT TUN
public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
    try (Statement stmt = connection.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next() ? mapRow(rs) : null;
    }
}

Wenn username ein gültiger Wert wie "minh" ist, wird die generierte SQL:

SELECT * FROM customers WHERE username = 'minh'

Aber wenn ein Angreifer username = "' OR '1'='1" übergibt, wird die SQL:

SELECT * FROM customers WHERE username = '' OR '1'='1'

Die Bedingung '1'='1' ist immer wahr, daher gibt die Query die gesamte Tabelle customers zurück — die Username-Validierungslogik wird vollständig umgangen. Noch schlimmer: Ein Angreifer könnte ; DROP TABLE customers; -- anhängen, um Daten zu löschen, oder UNION SELECT verwenden, um andere Tabellen zu lesen (z. B. die users-Tabelle mit gehashten Passwörtern).

Hinweis: Eingaben kommen nicht nur aus HTML-Formularen. HTTP-Header, Cookies, Query-Parameter und Datei-Upload-Daten sind alle potenzielle Angriffsquellen.

3. Wie PreparedStatement das Problem löst

3.1. Der Parametrisierungsmechanismus

PreparedStatement trennt die SQL-Struktur (wird zuerst gesendet, von der DB analysiert und in einen Ausführungsplan kompiliert) von den Parameterwerten (werden später gesendet, nur als reine Daten betrachtet). Dadurch wird die Angreifer-Eingabe niemals von der DB als SQL-Syntax interpretiert.

public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, username);
        try (ResultSet rs = stmt.executeQuery()) {
            return rs.next() ? mapRow(rs) : null;
        }
    }
}
  • Das ?-Zeichen ist ein Platzhalter; die DB kompiliert die Anweisung vorab mit diesem Platzhalter.
  • stmt.setString(1, username) bindet den Wert an die erste Parameterposition (nummeriert ab 1, nicht 0).
  • Auch wenn username ' OR '1'='1 enthält, versteht die DB dies als eine einzelne Zeichenkette, die abgeglichen werden soll, nicht als SQL-Syntax. Die Query gibt ein leeres Ergebnis zurück, da kein Benutzer diesen seltsamen Namen hat.

3.2. Named Parameter mit Spring JdbcTemplate

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>

Wenn Sie Spring verwenden, macht NamedParameterJdbcTemplate den Code lesbarer als ? mit Ordinalzahlen.

@Repository
public class CustomerRepository {

    private final NamedParameterJdbcTemplate jdbc;

    public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public Optional<Customer> findByUsername(String username) {
        String sql = "SELECT id, username, email FROM customers WHERE username = :username";
        Map<String, Object> params = Map.of("username", username);
        List<Customer> result = jdbc.query(sql, params, this::mapRow);
        return result.stream().findFirst();
    }

    private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
    }
}

:username ist ein benannter Platzhalter; Spring konvertiert ihn intern in ? und bindet den Wert sicher. Dies vermeidet Verwechslungen bei der Parameterreihenfolge, wenn die SQL-Anweisung viele Bedingungen hat.

4. Anwendung mit JPA/Hibernate

JPA generiert standardmäßig SQL mit Prepared Statements, aber Sie können sich selbst in den Fuß schießen, wenn Sie die API falsch verwenden.

// SICHER — JPQL mit benanntem Parameter
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// GEFÄHRLICH — Verkettung in JPQL
public List<Order> findOrdersByStatusUnsafe(String status) {
    String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
    return entityManager.createQuery(jpql, Order.class).getResultList();
}
  • setParameter("status", status) im sicheren Beispiel lässt Hibernate JPQL in SQL mit ? übersetzen, bindet dann den Wert über PreparedStatement — der Angreifer kann die Anweisungsstruktur nicht beeinflussen.
  • Im gefährlichen Beispiel parst Hibernate immer noch JPQL, aber die JPQL-Zeichenkette ist vor dem Parsen bereits „verschmutzt”, daher kann ein Angreifer JPQL-Injection durchführen (ähnlich wie SQL-Injection, nur auf einer anderen Syntaxebene).

Bei Spring Data JPA muss @Query auch Parameter verwenden, anstatt Zeichenketten zu verketten:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
    List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}

5. Fälle, die nicht parametrisiert werden können: Tabellen- und Spaltennamen

PreparedStatement kann nur Werte parametrisieren, nicht Identifier (Tabellen- und Spaltennamen, ORDER BY-Klauseln). Das ist eine Situation, die viele übersehen.

// Dynamische Sortierung nach einer vom Benutzer gewählten Spalte
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("Ungültige Sortierspalte: " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // sicher, weil auf Whitelist geprüft
    // ... mit PreparedStatement wie gewohnt ausführen
    return jdbc.query(sql, this::mapRow);
}
  • Da Sie ORDER BY ? nicht schreiben können, müssen Sie gültige Werte whitelisten, bevor Sie die Zeichenkette verketten.
  • Verwenden Sie niemals Blacklisten (Blockieren von Schlüsselwörtern wie DROP, ;), da Angreifer immer Wege haben, diese durch Kodierung oder Syntaxvarianten zu umgehen.

6. Tests schreiben, um Sicherheitslücken früh zu erkennen

Ein einfacher Test hilft Ihnen zu bestätigen, dass Ihr Endpoint Injection korrekt verhindert.

@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
        String payload = "' OR '1'='1";

        mockMvc.perform(get("/api/customers").param("username", payload))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
        mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
               .andExpect(status().isBadRequest());
    }
}
class CustomerRepositoryTest {

    @Test
    void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
        CustomerRepository repo = new CustomerRepository(jdbcTemplate);

        Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");

        assertThat(result).isEmpty();
    }
}
  • Der erste Test sendet eine klassische Injection-Payload und erwartet ein leeres Ergebnis (da kein Username dieser wörtlichen Zeichenkette entspricht) und nicht alle Daten.
  • Der zweite Test prüft, ob der Endpoint Eingaben ablehnt, die nicht auf der Whitelist für Sortierspalten stehen — er gibt 400 Bad Request zurück, anstatt gefährliches SQL auszuführen.
  • assertThat(result).isEmpty() verwendet AssertJ, um zu bestätigen, dass die Optional leer ist und die Query nicht umgangen wurde.

7. Häufige Fehler

  • Statement.executeQuery(String) für dynamische Eingaben verwenden: schnell beim Schreiben von Demos, aber eine echte Backdoor in der Produktion.
  • String.format verwenden, um SQL-Anweisungen zu „vereinfachen”: ist immer noch Zeichenkettenverkettung, nicht besser als +.
  • Dem ORM 100% vertrauen: JPQL/HQL/Criteria sind immer noch anfällig für Injection, wenn Sie die Zeichenkette selbst verketten, bevor Sie createQuery aufrufen.
  • Manuelles Escaping statt Parametrisierung: Selbst geschriebene Escape-Funktionen für einfache Anführungszeichen (replace("'", "\\'")) verpassen leicht Fälle (z. B. Unicode-Codierung, Multi-Byte) und sind nicht so zuverlässig wie der native Mechanismus des Treibers.
  • Dynamische IN-Klausel falsch gemacht: Beim Aufbau von WHERE id IN (?, ?, ?) mit variabler Parameteranzahl müssen Sie die richtige Anzahl von ? für die Listengröße generieren, nicht die Werte direkt verketten.

Hinweis: Prepared Statements schützen vor SQL-Injection, aber nicht vor Injection auf anderen Ebenen wie Command Injection, LDAP Injection oder NoSQL Injection. Jede Datenschicht benötigt ihren eigenen Parametrisierungsmechanismus.

8. Wann Sie PreparedStatement nicht benötigen

Wenn die SQL-Anweisung vollständig statisch ist und keine dynamischen Werte aus Benutzereingaben enthält (z. B. SELECT COUNT(*) FROM customers), ist die Verwendung eines normalen Statement immer noch sicher und manchmal etwas leichter, da die Parse-/Bind-Schritte übersprungen werden. In der Praxis haben jedoch die meisten Queries dynamische Bedingungen, daher sollten Sie standardmäßig PreparedStatement oder gleichwertige Alternativen (JPQL mit setParameter, MyBatis mit #{} statt ${}) verwenden — als Gewohnheit, um nicht merken zu müssen, „wann ist es sicher”.

9. Fazit

SQL-Injection entsteht durch das Vermischen von Code und Daten in einem SQL-String. PreparedStatement löst das grundlegend, indem es die beiden trennt: Die Anweisungsstruktur wird zuerst kompiliert, Parameterwerte werden später als reine Daten gebunden. Wenden Sie dies konsequent bei JDBC, JPQL und MyBatis an, verwenden Sie immer Whitelisting für dynamische Identifier wie Spalten- und Tabellennamen, und schreiben Sie Tests mit klassischen Injection-Payloads, um sicherzustellen, dass Ihr Endpoint niemals von einem Angreifer kontrolliertes SQL ausführt.

web-securitysql-injectionjdbcspring-bootjava