SQL Injection und wie man es verhindert?

Ausdauer oben

Ich habe gerade den neuen Learn Spring- Kurs angekündigt , der sich auf die Grundlagen von Spring 5 und Spring Boot 2 konzentriert:

>> Überprüfen Sie den Kurs

1. Einleitung

Obwohl SQL Injection eine der bekanntesten Sicherheitslücken ist, steht es weiterhin an der Spitze der berüchtigten OWASP Top 10-Liste - jetzt Teil der allgemeineren Injection- Klasse.

In diesem Lernprogramm werden häufig auftretende Codierungsfehler in Java untersucht, die zu einer anfälligen Anwendung führen, und wie diese mithilfe der in der Standard-Laufzeitbibliothek der JVM verfügbaren APIs vermieden werden können. Wir werden auch erläutern, welchen Schutz wir von ORMs wie JPA, Hibernate und anderen erhalten können und über welche blinden Flecken wir uns noch Sorgen machen müssen.

2. Wie werden Anwendungen für SQL Injection anfällig?

Injection-Angriffe funktionieren, da für viele Anwendungen die einzige Möglichkeit zum Ausführen einer bestimmten Berechnung darin besteht, dynamisch Code zu generieren, der wiederum von einem anderen System oder einer anderen Komponente ausgeführt wird . Wenn wir bei der Generierung dieses Codes nicht vertrauenswürdige Daten ohne ordnungsgemäße Bereinigung verwenden, lassen wir eine offene Tür für Hacker frei, die sie ausnutzen können.

Diese Aussage mag etwas abstrakt klingen. Schauen wir uns also anhand eines Lehrbuchbeispiels an, wie dies in der Praxis geschieht:

public List unsafeFindAccountsByCustomerId(String customerId) throws SQLException { // UNSAFE !!! DON'T DO THIS !!! String sql = "select " + "customer_id,acc_number,branch_id,balance " + "from Accounts where customer_id = '" + customerId + "'"; Connection c = dataSource.getConnection(); ResultSet rs = c.createStatement().executeQuery(sql); // ... }

Das Problem mit diesem Code liegt auf der Hand: Wir haben den Wert der customerId ohne Validierung in die Abfrage eingefügt . Es wird nichts Schlimmes passieren, wenn wir sicher sind, dass dieser Wert nur aus vertrauenswürdigen Quellen stammt. Können wir das?

Stellen sie sich vor , dass diese Funktion in einer REST - API - Implementierung für eine verwendet wird , Konto - Ressource. Das Ausnutzen dieses Codes ist trivial: Alles, was wir tun müssen, ist, einen Wert zu senden, der, wenn er mit dem festen Teil der Abfrage verkettet wird, sein beabsichtigtes Verhalten ändert:

curl -X GET \ '//localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \

Angenommen, der Parameterwert customerId bleibt deaktiviert, bis er unsere Funktion erreicht, erhalten wir Folgendes:

abc' or '1' = '1

Wenn wir diesen Wert mit dem festen Teil verbinden, erhalten wir die endgültige SQL-Anweisung, die ausgeführt wird:

select customer_id, acc_number,branch_id, balance from Accounts where customerId = 'abc' or '1' = '1'

Wahrscheinlich nicht das, was wir wollten ...

Ein kluger Entwickler (sind wir nicht alle?) Würde jetzt denken: „Das ist albern! Ich würde niemals eine Zeichenfolgenverkettung verwenden, um eine solche Abfrage zu erstellen. “

Nicht so schnell ... Dieses kanonische Beispiel ist in der Tat albern, aber es gibt Situationen, in denen wir es möglicherweise noch tun müssen :

  • Komplexe Abfragen mit dynamischen Suchkriterien: Hinzufügen von UNION-Klauseln in Abhängigkeit von vom Benutzer angegebenen Kriterien
  • Dynamische Gruppierung oder Reihenfolge: REST-APIs, die als Backend für eine GUI-Datentabelle verwendet werden

2.1. Ich benutze JPA. Ich bin in Sicherheit, richtig?

Dies ist ein weit verbreitetes Missverständnis . JPA und andere ORMs entlasten uns von der Erstellung handcodierter SQL-Anweisungen, hindern uns jedoch nicht daran, anfälligen Code zu schreiben .

Mal sehen, wie die JPA-Version des vorherigen Beispiels aussieht:

public List unsafeJpaFindAccountsByCustomerId(String customerId) { String jql = "from Account where customerId = '" + customerId + "'"; TypedQuery q = em.createQuery(jql, Account.class); return q.getResultList() .stream() .map(this::toAccountDTO) .collect(Collectors.toList()); } 

Das gleiche Problem, auf das wir bereits hingewiesen haben, ist auch hier vorhanden: Wir verwenden nicht validierte Eingaben, um eine JPA-Abfrage zu erstellen , sodass wir hier der gleichen Art von Exploit ausgesetzt sind.

3. Präventionstechniken

Nachdem wir nun wissen, was eine SQL-Injection ist, wollen wir sehen, wie wir unseren Code vor dieser Art von Angriff schützen können. Hier konzentrieren wir uns auf einige sehr effektive Techniken, die in Java und anderen JVM-Sprachen verfügbar sind. Ähnliche Konzepte stehen jedoch auch anderen Umgebungen wie PHP, .Net, Ruby usw. zur Verfügung.

Für diejenigen, die nach einer vollständigen Liste der verfügbaren Techniken suchen, einschließlich datenbankspezifischer Techniken, unterhält das OWASP-Projekt ein Spickzettel zur Verhinderung von SQL-Injektionen, in dem Sie mehr über das Thema erfahren können.

3.1. Parametrisierte Abfragen

Diese Technik besteht darin, vorbereitete Anweisungen mit dem Fragezeichen-Platzhalter ("?") In unseren Abfragen zu verwenden, wenn ein vom Benutzer angegebener Wert eingefügt werden muss. Dies ist sehr effektiv und, sofern es keinen Fehler in der Implementierung des JDBC-Treibers gibt, immun gegen Exploits.

Schreiben wir unsere Beispielfunktion neu, um diese Technik zu verwenden:

public List safeFindAccountsByCustomerId(String customerId) throws Exception { String sql = "select " + "customer_id, acc_number, branch_id, balance from Accounts" + "where customer_id = ?"; Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1, customerId); ResultSet rs = p.executeQuery(sql)); // omitted - process rows and return an account list }

Hier haben wir die Methode prepareStatement () verwendet, die in der Connection- Instanz verfügbar ist , um ein PreparedStatement abzurufen . Diese Schnittstelle erweitert die reguläre Anweisungsschnittstelle um mehrere Methoden, mit denen wir vom Benutzer bereitgestellte Werte sicher in eine Abfrage einfügen können, bevor sie ausgeführt wird.

Für JPA haben wir eine ähnliche Funktion:

String jql = "from Account where customerId = :customerId"; TypedQuery q = em.createQuery(jql, Account.class) .setParameter("customerId", customerId); // Execute query and return mapped results (omitted)

Wenn Sie diesen Code unter Spring Boot ausführen , können Sie die Eigenschaft logging.level.sql auf DEBUG setzen und sehen, welche Abfrage tatsächlich erstellt wird, um diesen Vorgang auszuführen:

// Note: Output formatted to fit screen [DEBUG][SQL] select account0_.id as id1_0_, account0_.acc_number as acc_numb2_0_, account0_.balance as balance3_0_, account0_.branch_id as branch_i4_0_, account0_.customer_id as customer5_0_ from accounts account0_ where account0_.customer_id=?

Wie erwartet erstellt die ORM-Schicht eine vorbereitete Anweisung unter Verwendung eines Platzhalters für den Parameter customerId . Dies ist das gleiche, was wir im einfachen JDBC-Fall getan haben - aber mit ein paar Aussagen weniger, was schön ist.

Als Bonus führt dieser Ansatz normalerweise zu einer Abfrage mit besserer Leistung, da die meisten Datenbanken den mit einer vorbereiteten Anweisung verknüpften Abfrageplan zwischenspeichern können.

Bitte beachten Sie, dass dieser Ansatz nur für Platzhalter funktioniert, die als Werte verwendet werden . Beispielsweise können wir keine Platzhalter verwenden, um den Namen einer Tabelle dynamisch zu ändern:

// This WILL NOT WORK !!! PreparedStatement p = c.prepareStatement("select count(*) from ?"); p.setString(1, tableName);

Auch hier hilft JPA nicht:

// This WILL NOT WORK EITHER !!! String jql = "select count(*) from :tableName"; TypedQuery q = em.createQuery(jql,Long.class) .setParameter("tableName", tableName); return q.getSingleResult(); 

In beiden Fällen wird ein Laufzeitfehler angezeigt.

Der Hauptgrund dafür liegt in der Natur einer vorbereiteten Anweisung: Datenbankserver verwenden sie, um den zum Abrufen der Ergebnismenge erforderlichen Abfrageplan zwischenzuspeichern, der normalerweise für jeden möglichen Wert gleich ist. Dies gilt nicht für Tabellennamen und andere in der SQL-Sprache verfügbare Konstrukte, z. B. Spalten, die in einer order by- Klausel verwendet werden.

3.2. JPA Criteria API

Da die explizite Erstellung von JQL-Abfragen die Hauptquelle für SQL-Injektionen ist, sollten wir nach Möglichkeit die Verwendung der Abfrage-API von JPA bevorzugen.

Eine kurze Einführung in diese API finden Sie im Artikel zu Abfragen der Kriterien für den Ruhezustand. Lesen Sie auch unseren Artikel über JPA Metamodel, in dem gezeigt wird, wie Metamodellklassen generiert werden, mit denen wir die für Spaltennamen verwendeten Zeichenfolgenkonstanten entfernen können - und die Laufzeitfehler, die bei Änderungen auftreten.

Schreiben wir unsere JPA-Abfragemethode neu, um die Kriterien-API zu verwenden:

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId)); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)

Hier haben wir mehr Codezeilen verwendet, um das gleiche Ergebnis zu erzielen , aber der Vorteil ist, dass wir uns jetzt nicht mehr um die JQL-Syntax kümmern müssen .

Ein weiterer wichtiger Punkt: Trotz ihrer Ausführlichkeit macht die Kriterien-API das Erstellen komplexer Abfragedienste einfacher und sicherer. Ein vollständiges Beispiel, das zeigt, wie dies in der Praxis funktioniert, finden Sie in dem Ansatz, der von von JHipster generierten Anwendungen verwendet wird.

3.3. Bereinigung von Benutzerdaten

Die Datenbereinigung ist eine Technik zum Anwenden eines Filters auf vom Benutzer bereitgestellte Daten, damit diese sicher von anderen Teilen unserer Anwendung verwendet werden können . Die Implementierung eines Filters kann sehr unterschiedlich sein, aber wir können sie im Allgemeinen in zwei Typen einteilen: Whitelists und Blacklists.

Blacklists , die aus Filtern bestehen, die versuchen, ein ungültiges Muster zu identifizieren, sind im Kontext der SQL Injection-Verhinderung normalerweise von geringem Wert - aber nicht für die Erkennung! Dazu später mehr.

Whitelists hingegen funktionieren besonders gut, wenn wir genau definieren können, was eine gültige Eingabe ist.

Lassen Sie uns unsere safeFindAccountsByCustomerId- Methode erweitern, sodass der Aufrufer jetzt auch die Spalte angeben kann, die zum Sortieren der Ergebnismenge verwendet wird. Da wir die Menge der möglichen Spalten kennen, können wir eine Whitelist mit einer einfachen Menge implementieren und damit den empfangenen Parameter bereinigen:

private static final Set VALID_COLUMNS_FOR_ORDER_BY = Collections.unmodifiableSet(Stream .of("acc_number","branch_id","balance") .collect(Collectors.toCollection(HashSet::new))); public List safeFindAccountsByCustomerId( String customerId, String orderBy) throws Exception { String sql = "select " + "customer_id,acc_number,branch_id,balance from Accounts" + "where customer_id = ? "; if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) { sql = sql + " order by " + orderBy; } else { throw new IllegalArgumentException("Nice try!"); } Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1,customerId); // ... result set processing omitted }

Hier kombinieren wir den vorbereiteten Anweisungsansatz und eine Whitelist, mit der das orderBy- Argument bereinigt wird . Das Endergebnis ist eine sichere Zeichenfolge mit der endgültigen SQL-Anweisung. In diesem einfachen Beispiel verwenden wir einen statischen Satz, aber wir hätten auch Datenbankmetadatenfunktionen verwenden können, um ihn zu erstellen.

We can use the same approach for JPA, also taking advantage of the Criteria API and Metadata to avoid using String constants in our code:

// Map of valid JPA columns for sorting final Map
    
      VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of( new AbstractMap.SimpleEntry(Account_.ACC_NUMBER, Account_.accNumber), new AbstractMap.SimpleEntry(Account_.BRANCH_ID, Account_.branchId), new AbstractMap.SimpleEntry(Account_.BALANCE, Account_.balance)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); SingularAttribute orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy); if (orderByAttribute == null) { throw new IllegalArgumentException("Nice try!"); } CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root) .where(cb.equal(root.get(Account_.customerId), customerId)) .orderBy(cb.asc(root.get(orderByAttribute))); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)
    

This code has the same basic structure as in the plain JDBC. First, we use a whitelist to sanitize the column name, then we proceed to create a CriteriaQuery to fetch the records from the database.

3.4. Are We Safe Now?

Let's assume that we've used parameterized queries and/or whitelists everywhere. Can we now go to our manager and guarantee we're safe?

Well… not so fast. Without even considering Turing's halting problem, there are other aspects we must consider:

  1. Stored Procedures: These are also prone to SQL Injection issues; whenever possible please apply sanitation even to values that will be sent to the database via prepared statements
  2. Triggers: Same issue as with procedure calls, but even more insidious because sometimes we have no idea they're there…
  3. Insecure Direct Object References: Even if our application is SQL-Injection free, there's still a risk that associated with this vulnerability category – the main point here is related to different ways an attacker can trick the application, so it returns records he or she was not supposed to have access to – there's a good cheat sheet on this topic available at OWASP's GitHub repository

In short, our best option here is caution. Many organizations nowadays use a “red team” exactly for this. Let them do their job, which is exactly to find any remaining vulnerabilities.

4. Damage Control Techniques

As a good security practice, we should always implement multiple defense layers – a concept known as defense in depth. The main idea is that even if we're unable to find all possible vulnerabilities in our code – a common scenario when dealing with legacy systems – we should at least try to limit the damage an attack would inflict.

Of course, this would be a topic for a whole article or even a book but let's name a few measures:

  1. Apply the principle of least privilege: Restrict as much as possible the privileges of the account used to access the database
  2. Use database-specific methods available in order to add an additional protection layer; for example, the H2 Database has a session-level option that disables all literal values on SQL Queries
  3. Use short-lived credentials: Make the application rotate database credentials often; a good way to implement this is by using Spring Cloud Vault
  4. Log everything: If the application stores customer data, this is a must; there are many solutions available that integrate directly to the database or work as a proxy, so in case of an attack we can at least assess the damage
  5. Verwenden Sie WAFs oder ähnliche Intrusion Detection-Lösungen: Dies sind die typischen Beispiele für schwarze Listen. In der Regel enthalten sie eine umfangreiche Datenbank mit bekannten Angriffssignaturen und lösen bei der Erkennung eine programmierbare Aktion aus. Einige enthalten auch In-JVM-Agenten, die durch Anwendung von Instrumenten Einbrüche erkennen können. Der Hauptvorteil dieses Ansatzes besteht darin, dass eine eventuelle Sicherheitsanfälligkeit viel einfacher zu beheben ist, da ein vollständiger Stack-Trace verfügbar ist.

5. Schlussfolgerung

In diesem Artikel haben wir uns mit SQL Injection-Schwachstellen in Java-Anwendungen befasst - eine sehr ernsthafte Bedrohung für jedes Unternehmen, das für sein Unternehmen auf Daten angewiesen ist - und wie verhindert werden kann, dass sie mithilfe einfacher Techniken verwendet werden.

Wie üblich ist der vollständige Code für diesen Artikel auf Github verfügbar.

Persistenz unten

Ich habe gerade den neuen Learn Spring- Kurs angekündigt , der sich auf die Grundlagen von Spring 5 und Spring Boot 2 konzentriert:

>> Überprüfen Sie den Kurs