Menu

SQL-Injection in SQLite verhindern mit Prepared Statements

Warum String-Konkatenation in SQL-Queries brandgefährlich ist, wie SQL-Injection wirklich funktioniert – und wie du sie mit parametrisierten Queries in SQLite zuverlässig abstellst.

Diese Seite enthält ausführbare Editoren — bearbeiten, ausführen und Ausgabe sofort sehen.

SQL Injection ist im Kern ein String-Bau-Bug

SQL-Injection in SQLite entsteht immer dann, wenn Nutzereingaben als Teil des SQL-Texts in der Datenbank landen. Sobald diese Grenze verschwimmt – sobald ein vom Nutzer eingetippter Wert zu Syntax wird, die die Datenbank ausführt – kann der Nutzer alles tun, was du auch tun kannst.

Hier das klassische Anti-Pattern als Pseudocode, den jede Sprache so produzieren kann:

-- MACH DAS NICHT
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

Wenn user_input den Wert Ada hat, läuft alles wie erwartet. Setzt jemand stattdessen ' OR 1=1 -- ein, wird daraus:

SELECT * FROM users WHERE name = '' OR 1=1 --'

Mit -- wird das schließende Anführungszeichen auskommentiert, OR 1=1 matcht jede Zeile – und schon hat der Angreifer deine User-Tabelle abgegriffen. In schlimmeren Varianten wird per ; ein zweites Statement angehängt, das Tabellen droppt, Daten exfiltriert oder einen neuen Admin-Account einfügt.

Die Schwachstelle liegt nicht in SQLite, sondern im Code, der diesen String zusammengebaut hat.

Parametrisierte Queries: die eigentliche Lösung

Eine parametrisierte Query trennt den SQL-Text von den Werten. Im SQL stehen Platzhalter – ? oder :name – und die Werte übergibst du separat. SQLite parst und kompiliert das SQL einmal und bindet deine Werte anschließend in den fertigen Ausführungsplan ein. Aus den Werten kann so niemals SQL werden.

So führst du eine auf den ersten Blick angreifbare Abfrage sicher aus:

In der SQLite-Shell tippst du den Wert einfach direkt ein – im Anwendungscode sieht das Ganze dann aber so aus (hier mit Pythons sqlite3-Treiber):

# Python — parametrisiert, sicher
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

SQL und das Tupel mit den Werten übergibst du als zwei getrennte Argumente. Der Treiber schickt beides separat an SQLite. Selbst wenn user_input den Wert ' OR 1=1 -- hat, sucht SQLite nach einem Nutzer, der buchstäblich ' OR 1=1 -- heißt – und findet logischerweise keinen.

Was „sicher" hier wirklich bedeutet

Die Sicherheit kommt nicht durch Pattern-Matching oder Escaping zustande, sondern ist strukturell bedingt. SQLite kompiliert das Statement in eine interne Form, noch bevor dein Wert überhaupt ins Spiel kommt:

-- Die kompilierte Anweisung hat einen Platzhalter, keinen String.
SELECT * FROM users WHERE name = ?
                                 ^
                                 Platzhalter-Slot

Wenn du einen Wert bindest, landet er als typisiertes Datum im Platzhalter – TEXT, INTEGER, BLOB, was auch immer. SQLite parst diesen Wert niemals erneut als SQL. Für den Angreifer gibt es schlicht keine Syntax mehr, in die er sich einklinken könnte, denn der Parser ist mit seiner Arbeit längst durch.

Genau deshalb sind parametrisierte Queries in SQLite so verlässlich, wie es Escaping nie sein kann. Escaping versucht, gefährliche Zeichen aus einem String herauszuputzen. Beim Binden entsteht der gefährliche String dagegen erst gar nicht.

Finger weg von String-Formatierung

Jede Sprache hat ihre verlockende Abkürzung – f-Strings in Python, Template Literals in JavaScript, String.format in Java – und jede einzelne davon ist bei SQL eine geladene Waffe, die auf den eigenen Fuß zielt.

# NICHT TUN — f-string interpoliert den Wert in den SQL-Text
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# NICHT TUN — gleiches Problem, %-Formatierung
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

# SO MACHEN — Platzhalter + values-Argument
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

Die ersten beiden Varianten basteln die Nutzereingabe direkt in den SQL-String, bevor der Treiber überhaupt etwas davon mitbekommt. Wenn die Query bei SQLite ankommt, ist das Kind längst in den Brunnen gefallen. Die dritte Variante hält SQL und Wert sauber getrennt.

Die Regel ist simpel und mechanisch: Sobald du dabei bist, einen SQL-String mit +, f-Strings, format oder Template-Literalen an der Stelle eines Werts zusammenzubauen – stopp, nimm stattdessen einen Platzhalter.

Mehrere Parameter und benannte Platzhalter

In der Praxis hat eine Query meist mehr als nur einen Wert. SQLite unterstützt dafür sowohl positionale Platzhalter mit ? als auch benannte Platzhalter im Stil :name:

Im Anwendungscode sieht das dann so aus:

# Positional
cursor.execute(
    "SELECT * FROM orders WHERE customer = ? AND status = ?",
    ("Ada", "paid"),
)

# Benannt — übersichtlicher, wenn es mehrere Parameter gibt
cursor.execute(
    "SELECT * FROM orders WHERE total > :min_total AND status = :status",
    {"min_total": 50, "status": "paid"},
)

Benannte Parameter skalieren besser. Sobald du mehr als drei oder vier Werte hast, wird ?, ?, ?, ? zum Ratespiel – :customer, :total, :status, :created_at dagegen erklärt sich von selbst.

Bezeichner brauchen einen anderen Ansatz

Bind-Parameter funktionieren ausschließlich für Werte – also das, was rechts vom = steht, innerhalb von IN (...) oder in VALUES (...). Für Tabellennamen, Spaltennamen oder SQL-Schlüsselwörter wie ASC/DESC lassen sie sich nicht einsetzen.

-- Das funktioniert NICHT. Der Platzhalter kann keinen Spaltennamen ersetzen.
SELECT * FROM users ORDER BY ? ASC

Wenn du einen dynamischen Bezeichner brauchst – etwa damit Nutzer selbst entscheiden können, nach welcher Spalte sortiert wird – prüfe den Wert vorher gegen eine Allowlist, bevor du das SQL zusammenbaust:

# Allowlist-Ansatz
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}

if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError(f"Ungültige Sortierspalte: {sort_column}")

query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)

Die vom Nutzer übergebene Eingabe wird gegen eine feste Liste bekannter, sicherer Werte geprüft, bevor sie überhaupt in die Nähe des SQL-Statements kommt. Der f-String ist hier nur deshalb in Ordnung, weil sort_column ausschließlich einen von drei fest verdrahteten Namen annehmen kann.

SQL-Injection-Beispiel: Angriff abgewehrt

Schauen wir uns beide Varianten direkt nebeneinander an – mit einer bösartigen Eingabe. Dazu legen wir uns eine kleine users-Tabelle an:

Das verwundbare Formular liefert sämtliche Nutzer zurück. Die parametrisierte Variante sucht dagegen wortwörtlich nach einem User mit dem Namen ' OR 1=1 -- und findet niemanden. Gleiche Eingabe, völlig unterschiedliches Ergebnis — denn im zweiten Fall ist der Wert nie zu SQL geworden.

Kurze Checkliste

  • Setze ? oder :name als Platzhalter für jeden Wert, der von außen kommt — Nutzereingaben, Request-Bodies, Umgebungsvariablen, alles, was du nicht selbst hart in den Code geschrieben hast.
  • Bau SQL niemals mit +, f-Strings oder format zusammen, sobald irgendwo ein Wert eingefügt wird.
  • Bei dynamischen Tabellen- oder Spaltennamen: vorher gegen eine fest definierte Allowlist prüfen, bevor sie in die Query wandern.
  • Vertraue dem Treiber. Schreib keine eigene Escape-Funktion. Der Mechanismus für Bind-Parameter ist älter, härter im Praxiseinsatz erprobt — und korrekt.
  • Geh die Queries deines Teams mit einer einzigen Frage durch: Wird hier irgendwo Nutzereingabe direkt in den SQL-Text reingehängt? Falls ja: reparieren.

Wenn dir das in Fleisch und Blut übergegangen ist, hört SQL-Injection auf, eine Bug-Klasse zu sein, über die du noch aktiv nachdenken musst.

Weiter: Anbindung aus Anwendungen heraus

Du kennst jetzt die sichere Form einer Query — Platzhalter im SQL, Wert daneben übergeben. Auf der nächsten Seite verkabeln wir SQLite aus echtem Anwendungscode in Python, Node.js und ein paar weiteren Sprachen, inklusive Connection-Management und der Frage, an welcher Stelle parametrisierte Queries in einem typischen Request-Flow sitzen.

Häufig gestellte Fragen

Ist SQLite anfällig für SQL-Injection?

Ja. SQLite ist genauso anfällig wie jede andere SQL-Datenbank, sobald dein Anwendungscode Queries per String-Konkatenation zusammenbaut. Die Lösung steckt nicht in irgendeiner SQLite-Einstellung, sondern darin, wie du Werte aus deiner Anwendung übergibst: Nutze parametrisierte Queries mit ?- oder :name-Platzhaltern, dann kümmert sich der Treiber um den Rest.

Wie schützen parametrisierte Queries vor SQL-Injection?

Wenn du Platzhalter wie ? verwendest, parst und kompiliert SQLite das Statement zuerst und bindet erst danach deine Werte in die fertigen Slots. Diese Werte können also nie zu SQL-Syntax werden – sie sind reine Daten, Punkt. Es gibt schlicht keinen String, aus dem ein Angreifer ausbrechen könnte.

Kann ich Anführungszeichen in Nutzereingaben nicht einfach selbst escapen?

Nein. Manuelles Escaping ist viel zu fehleranfällig – irgendeinen Edge Case (Unicode-Anführungszeichen, Encoding-Tricks, Kommentarzeichen) übersiehst du garantiert und schickst eine Lücke in Produktion. Die Treiber bieten ? und :name genau deshalb an, damit du dir über Escaping gar keine Gedanken machen musst. Nutze sie konsequent, auch bei Werten, denen du angeblich vertraust.

Was ist mit Tabellen- oder Spaltennamen, die aus Nutzereingaben kommen?

Bind-Parameter funktionieren ausschließlich für Werte, nicht für Bezeichner. Wenn ein Tabellen- oder Spaltenname dynamisch sein muss, prüfe ihn vorher gegen eine Allowlist bekannter Namen, bevor er ins SQL eingesetzt wird. Einen rohen, vom Nutzer gelieferten Bezeichner per String-Formatting in eine Query zu schreiben, ist tabu.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S