SQL-Injection-Schwachstellen sind zwar inzwischen nicht mehr ganz so häufig, aber auch heute finden wir noch welche in unseren Penetrationstests auf Anwendungsebene. Im Übrigen macht es keinen Unterschied, ob Webanwendung, Rich-Client oder etwas ganz anderes: keine Anwendungsart ist davor gefeit.
SQL-Injections sind ein ernsthaftes Problem, die es erlauben, beliebige Befehle innerhalb der Datenbank auszuführen. Damit können zumindest alle Daten ausgelesen werden – Berechtigungskonzepte, die im Applikationsserver hinterlegt sind, greifen dabei natürlich nicht. In manchen Fällen ist es auch möglich, beliebigen Code auf Betriebssystemebene auszuführen. Die komplette Übernahme des Servers ist damit nur noch eine Frage der Zeit.
Was ist eine SQL-Injection?
SQL-Injections sind immer dann möglich, wenn benutzerkontrollierte Daten ungefiltert in SQL-Abfragen eingebaut werden. Im Folgenden soll ein typisches Beispiel dargestellt werden: die Suchfunktion. Folgender Pseudocode soll das Zusammenbauen der Abfrage verdeutlichen:
query = 'SELECT article, title FROM our_texts WHERE title LIKE "%" + request.getParameter("searchString") + '%'";
Wird als Suchstring beispielweise „sicherheit“ übergeben, sieht die Abfrage an die Datenbank so aus:
SELECT article, title FROM our_texts WHERE title LIKE "%sicherheit%"
So weit, so gut. Aus dem Pseudocode ist klar ersichtlich, dass der übergebene Parameter searchString ungefiltert in die Abfrage eingebaut wird. Das kann nun zu einer SQL-Injection ausgebaut werden. Beispielsweise kann folgender searchString eingesetzt werden:
" OR 1=1 --
Das ist die einfachste Form einer SQL-Injection. Die Bedingung „1=1“ ist immer wahr, weswegen alle Datensätze zurückgegeben werden. Die Abfrage sieht also so aus:
SELECT article, title FROM our_texts WHERE title LIKE "%" OR 1=1 -- %"
Die doppelten Bindestriche dienen in diesem Fall als Kommentar, sodass wir uns um das Prozentzeichen und das letzte Anführungszeichen nicht kümmern müssen. Würden wir das nicht tun, käme eine SQL-Fehlermeldung zurück, weil die Abfrage ungültig wäre.
Konkret ausnutzen lässt sich das natürlich auch, indem ein UNION verwendet wird. UNION erlaubt es, verschiedene Abfragen zu kombinieren und in einem Ergebnis zurückzugeben. Folgender „searchString“ würde alle Benutzernamen und Passwörter zurückgeben:
" UNION SELECT username, password FROM user --
Die Abfrage würde sich wie folgt ändern:
SELECT article, title FROM our_texts WHERE title LIKE "%" UNION SELECT username, password FROM user -- %"
Fertig ist unsere SQL-Injection.
Was ist eine Blind SQL-Injection?
Eine Blind-SQL-Injection ist eine besondere Form von SQL-Injection, bei der die Einschränkung besteht, Daten nur indirekt auslesen zu können. Das ist beispielsweise bei Abfragen der Fall, die nur im Programmablauf verwendet werden, jedoch nicht direkt angezeigt werden.
Folgendes Beispiel soll den Ablauf verdeutlichen:
getMessagesQuery = 'SELECT count(*) FROM messages WHERE username = "' + getCookie("username") + '"'; […] if (messagesCount > 0) { print("Sie haben neue Nachrichten"); } else { print("Sie haben keine neuen Nachrichten"); }
Dass es grundsätzlich eine sehr schlechte Idee ist, solche Variablen wie Benutzername als Cookie zu speichern, soll hier nur nebenbei erwähnt werden. Für die Demonstration einer Blind-SQL-Injection funktioniert das aber.
Dies auszunutzen ist zwar etwas schwieriger, aber immer noch möglich. Die einfachste Möglichkeit ist, eine Verzögerung einzubauen. In MySQL geht dies beispielsweise mit BENCHMARK, mit MSSQL tut es auch ein WAITFOR DELAY. Man spricht dann von einer Time-based blind SQL-Injection.
Das könnte ausgenutzt werden, indem als Cookie folgender gesetzt wird:
" UNION SELECT IF(SUBSTR(password, 1, 1) = CHAR(36), BENCHMARK(10000000,AES_ENCRYPT('hello','goodbye')), 2) FROM users WHERE username = "admin
Komplett sieht die Abfrage dann so aus:
SELECT count(*) FROM messages WHERE username = "" UNION SELECT IF(SUBSTR(password, 1, 1) = CHAR(36), BENCHMARK(10000000,AES_ENCRYPT('hello','goodbye')), 2) FROM users WHERE username = "admin"
Diese Abfrage prüft, ob das erste Zeichen des Passwort-Hashes ein $-Zeichen ist. Wenn dem so ist, wird ein Benchmark ausgeführt, in diesem Fall wird ein String sehr häufig mit AES verschlüsselt, was zu einer spürbaren Verzögerung führt. Wenn die Annahme nicht stimmt, wird die Abfrage sofort ausgeführt. Man muss also für jedes Zeichen des Passwort-Hashes durchprobieren, was der Wert ist. Das dauert zwar, aber dank automatisierten Tools ist dies kein Problem.
Wie vermeide ich SQL-Injection-Schwachstellen?
Es gibt grundsätzlich zwei Ansätze, wie SQL-Injections vermieden werden können. Der eine Ansatz ist das Maskieren wichtiger Steuerzeichen (Escaping), der andere die Verwendung von prepared Statements. Vom Escaping möchte ich abraten. Es gibt dort leider ein paar Fallstricke, die man beachten muss und bei manchen Abfragen hilft Escaping überhaupt nichts.
Die im Folgenden dargestellte Abfrage ist so ein Fall:
query = "SELECT article, text FROM our_texsts WHERE id = " + getParameter(id);
Selbst wenn die üblichen Zeichen wie doppelte und einfache Anführungszeichen maskiert sind, ist noch eine SQL-Injection möglich, da Zahlen (in diesem Fall die id) nicht in Anführungszeichen eingeschlossen sind. Hinzu kommen noch mögliche Probleme beim Einsatz von unterschiedlichen Zeichensätzen, die zusätzlich ein Escaping verhindern können.
Aus diesem Grund ist stets die Verwendung von prepared Statements vorzuziehen. Dabei werden die Struktur und die Daten für die Abfrage getrennt übertragen. Das hat zur Folge, dass sich die Datenbank um die richtige Verarbeitung kümmern kann.
Die Abfrage sieht dann folgendermaßen aus:
query = "SELECT article, text FROM our_texts WHERE id = ?"
Diese Abfrage wird dann in einem gesonderten Aufruf vorbereitet (prepare) und anschließend werden die einzelnen Variablen gebunden (bind). Zum Abschluss wird die Abfrage tatsächlich ausgeführt.
Wichtig ist dabei nur, dass bei prepared Statements tatsächlich auch immer Platzhalter verwendet werden. Kommen nämlich wieder einfache Stringverkettungen zum Einsatz, sind diese nach wie vor anfällig für SQL-Injections!
Pro-Tipp: Objektrelationale Mapper verwenden
Um sich den ganzen Stress zu sparen und gleichzeitig noch unabhängig von der darunterliegenden Datenbank zu sein, kann natürlich auch ein objektrelationaler Mapper (ORM) verwendet werden. Ein bekannter Vertreter aus der Java-Welt ist beispielsweise Hibernate, für Python ist ein SQLAlchemy ein bekannter ORM.
Diese haben den Vorteil, dass man sich um die Erstellung der prepared Statements nicht kümmern muss, da immer nur über Schnittstellen auf die Datenbank zugegriffen wird. Wichtig ist nur, dass man nicht am ORM vorbei entwickelt. Alle ORM bieten nämlich die Möglichkeit, auch direkte Befehle an die Datenbank abzusetzen.
Fazit
SQL-Injections sind fatale Schwachstellen, die man aber sehr leicht in den Griff bekommt. Eine Möglichkeit ist es, komplett auf prepared Statements zu wechseln, da diese den Schutz vor SQL-Injections massiv erleichtern.
Noch einfacher geht es, wenn man objektrelationale Mapper (ORM) verwendet. Diese kümmern sich um die korrekte Erstellung der Abfragen und unterstützen gleichzeitig dabei, von der darunterliegenden Datenbank unabhängig zu entwickeln.