Unsichere Datenbankabfragen finden und korrigieren

SQLiteBeginner
Jetzt üben

Einführung

Ein vom Benutzer angegebener Name sollte ein Wert bleiben und nicht Teil eines Datenbankbefehls werden. Diese DeskLite-Suche baut SQL-Text derzeit direkt aus einem Namen auf. Gewöhnliche Anfragen scheinen zu funktionieren, doch Namen mit Apostrophen und speziell gestaltete Eingaben machen das Problem sichtbar.

Du liest nur eine fünfzeilige Python-Funktion, lernst den kleinen darin verwendeten SQL-Umfang kennen, verfolgst einen echten Semgrep-Fund und wendest ein Muster zur SQLite-Parameterbindung an. Python- oder SQL-Vorkenntnisse sind nicht erforderlich. Aufrufprogramm, Datenbank, Scanner und Funktionsprüfung sind bereits vorbereitet.

Eine gewöhnliche Suche ausführen

In diesem Schritt stellst du fest, was die Suche zurückgeben soll, bevor du ihre Implementierung liest.

DeskLite speichert drei fiktive Tickets in einer kleinen SQLite-Datenbank. SQLite ist eine Datenbank-Engine, die eine lokale Datei liest und abfragt. Verbindung und Daten wurden für den Kurs bereits vorbereitet.

cd /home/labex/project/ticket-queries

run_lookup.py ist ein vorbereiteter Einstiegspunkt. Wie im Abhängigkeits-Lab führt python3 ein bereitgestelltes Programm aus. Das letzte Argument ist der zu suchende Name:

python3 run_lookup.py Alice

Erwartet wird ein Ticket mit der ID 1 und dem Titel Printer offline. Das Aufrufprogramm formatiert Datenbankzeilen zur vertrauten JSON-Antwort. Du bearbeitest die kleine Suchfunktion, nicht den Formatierungscode des Aufrufprogramms.

Die beabsichtigte Grenze ist einfach: Der angegebene Name wählt die Tickets dieses Eigentümers aus. Er soll weder die Bedeutung des Datenbankbefehls ändern noch Tickets anderer Eigentümer offenlegen. Alle Namen und Datensätze dieser Übung sind fiktiv; arbeite ausschließlich mit dieser vorbereiteten lokalen Datenbank.

Die fünfzeilige Funktion lesen

In diesem Schritt verfolgst du den angegebenen Namen durch eine kleine Python-Funktion.

cat lookup.py

Die Datei enthält:

from fixture import db

def lookup(name):
    query = f"SELECT id, title FROM tickets WHERE owner = '{name}'"
    return db.execute(query).fetchall()

from fixture import db stellt die vorbereitete Datenbankverbindung unter dem Namen db bereit. Eine Funktion ist ein benannter, wiederverwendbarer Verhaltensbaustein. def lookup(name): definiert eine Funktion, deren Eingabe name heißt. Die eingerückten Zeilen gehören zu dieser Funktion; behalte ihre Einrückung beim Bearbeiten bei.

Eine Variable gibt einem Wert einen Namen. query = ... weist query einen Textwert zu, eine sogenannte Zeichenfolge oder einen String. Das vorangestellte f macht daraus einen f-String: Python setzt den aktuellen Wert von name an der Stelle {name} ein. Lautet die Eingabe Alice, wird dieser Text Teil der SQL-Zeichenfolge.

db.execute(query) ist ein funktionsähnlicher Methodenaufruf, der die Verbindung auffordert, die Abfrage auszuführen. .fetchall() sammelt ihre Ergebniszeilen. return gibt diese Zeilen an den Aufrufer zurück. Das vorbereitete Aufrufprogramm übergibt dein Terminalargument an lookup und zeigt die zurückgegebenen Zeilen an.

Du brauchst nur diesen Datenweg: angegebener Name → Abfragezeichenfolge → Datenbankaufruf → zurückgegebene Zeilen. Als Nächstes liest du, was diese SQL-Zeichenfolge bedeutet.

Die beabsichtigte SQL-Bedingung lesen

In diesem Schritt verknüpfst du die SQL-Bedingung mit den Datensätzen, die zurückgegeben werden sollen.

SQL ist eine Sprache zum Anfordern von Datenbankoperationen. Diese Abfrage verwendet nur einen kleinen Teil davon:

SELECT id, title FROM tickets WHERE owner = 'Alice'

SELECT id, title wählt die beiden Ergebnisfelder aus. FROM tickets wählt die vorbereitete Tabelle aus. WHERE owner = 'Alice' beschränkt die Ergebnisse auf Zeilen, deren Eigentümer diesem Textwert entspricht. In SQL kennzeichnen die einfachen Anführungszeichen ein Textliteral. Das = innerhalb dieser SQL-Zeichenfolge drückt die Eigentümerbedingung aus. Die Python-Zuweisung query = ... außerhalb der Zeichenfolge gibt dem fertigen Text einen Variablennamen.

Versuche einen weiteren gewöhnlichen Namen:

python3 run_lookup.py Bob

Erwartet wird nur die Ticket-ID 3 mit dem Titel Spare keyboard. Der Name ändert den vorgesehenen Wert der Eigentümerbedingung, nicht die ausgewählten Spalten oder die Tabelle.

Verknüpfe dies nun mit dem gelesenen f-String. Python setzt den angegebenen Namen zwischen die SQL-Anführungszeichen, bevor SQLite die Abfrage sieht. Dadurch werden zwei unterschiedliche Dinge vermischt: die Struktur des Befehls und die Eingabedaten. Ein Apostroph in einem echten Namen kann diese Grenzen stören.

Bearbeite noch nichts. Zuerst findest du mit dem festgelegten Scanner die riskante Konstruktion. Danach bestätigst du das Verhalten mit einem legitimen Namen mit Apostroph und einer erlaubten lokalen Testeingabe.

Den Fund der statischen Analyse verfolgen

In diesem Schritt liest du einen echten Semgrep-Fund und ordnest seine Position dem Aufbau der Abfrage zu.

Statische Anwendungssicherheitsprüfung (SAST) untersucht Code, ohne jede mögliche Eingabe auszuführen. Der vorbereitete Starter course-code-scan verwendet die festgelegte Engine Semgrep CE 1.145.0 und eine feste lokale Regel. Es gibt weder ein Cloud-Konto noch ein Online-Regelwerk.

course-code-scan lookup.py

Erwartet wird ein Fund bei lookup.py:5 mit der Regel-ID desklite.sql-parameter-binding und dieser Erklärung:

Bind the supplied lookup name as a SQL parameter instead of building SQL text from it.

Die Zeile verweist auf die Verwendung von query im Datenbankaufruf. Verfolge diesen Wert eine Zeile nach oben, um zu sehen, wo der f-String aufgebaut wurde. Dies ist eine kurze Untersuchung des Datenflusses, keine Aufforderung zur Entwicklung einer vollständigen Anwendung.

Ein Fund führt zum Exit-Status 1; CHECK ERROR führt zu 2, wenn die Datei nicht analysiert werden kann. Keines von beiden ist ein Ergebnis ohne Befund. Die lokale Regel verfolgt den in diesem Kurs angegebenen Wert name bis in den SQL-Text. Ihr Umfang ist begrenzt: Ein Fund erfordert weiterhin eine Codeprüfung, und ein ausbleibender Fund unter einer einzelnen Regel beweist nicht, dass eine Anwendung keine weiteren Risiken hat.

Als Nächstes vergleichst du den Fund mit dem tatsächlichen Suchverhalten.

Text mit Apostroph und abfrageverändernde Eingabe vergleichen

In diesem Schritt beobachtest du ausschließlich anhand der vorbereiteten lokalen Testdaten, warum Eingaben Daten bleiben müssen.

Ein legitimer Eigentümer heißt O'Neil. Doppelte Anführungszeichen halten dieses gesamte Terminalargument einschließlich seines Apostrophs zusammen:

python3 run_lookup.py "O'Neil"

Erwartet wird eine mit Lookup failed: beginnende Zeile mit einem SQLite-Syntaxfehler. Dieser Fehler ist ein beabsichtigter Nachweis des Defekts. Der Apostroph im Namen hat ein SQL-Textliteral beendet, weil das Programm ihn direkt in den Befehlstext eingefügt hat. Jeden Apostroph abzulehnen würde die Suche nach einem echten Eigentümer verhindern.

Versuche nun diese begrenzte Testeingabe:

python3 run_lookup.py "' OR 1=1 --"

Die unsichere Version gibt alle drei Tickets zurück, statt keinen passenden Eigentümer zu finden. In diesem SQL-Kontext schließt das Anführungszeichen das Literal, OR 1=1 macht die Bedingung wahr, und -- kommentiert den restlichen Text aus. Die Eingabe hat die Befehlsstruktur verändert, statt ein Name zu bleiben. Dies ist SQL-Injection.

Du testest keinen externen Dienst und änderst keine Daten. Die vorbereitete Datenbank ist schreibgeschützt und enthält fiktive Datensätze. Die erforderliche Korrektur muss beiden Beobachtungen gerecht werden: Ein legitimer Name mit Apostroph funktioniert, während abfrageverändernder Text die zurückgegebenen Zeilen nicht erweitern kann.

Den Namen getrennt vom SQL-Text binden

In diesem Schritt korrigierst du die Suche mit der SQLite-Schnittstelle zur Parameterbindung.

Ein Platzhalter markiert, wo ein Datenwert hingehört. Der SQLite-Platzhalter ? ist Teil der festen SQL-Struktur; der Name wird getrennt an execute übergeben. Der Datenbanktreiber behandelt den übergebenen Wert als Daten, auch wenn er Anführungszeichen oder SQL-ähnliche Zeichen enthält.

nano lookup.py

Ersetze die Abfrage- und Ausführungszeile der Funktion durch dieses unterstützte Muster. Behalte Import, Funktionsnamen und Einrückung bei. Speichere mit Ctrl+O, Enter, Ctrl+X:

from fixture import db

def lookup(name):
    query = "SELECT id, title FROM tickets WHERE owner = ?"
    return db.execute(query, (name,)).fetchall()

Setze keine SQL-Anführungszeichen um ?. Der Treiber übernimmt die Darstellung des Werts. (name,) ist ein Tupel mit einem Element, also eine geordnete Sammlung von Werten. Das abschließende Komma macht es zu einem Tupel; sein einziger Wert entspricht dem einzigen Platzhalter. Das zweite Argument von execute übergibt dieses Tupel getrennt vom Abfragetext.

Führe eine gewöhnliche Suche aus:

python3 run_lookup.py Alice

Versuche dann erneut den legitimen Namen mit Apostroph:

python3 run_lookup.py "O'Neil"

Erwartet werden die Ticket-ID 1 für Alice und die Ticket-ID 2 mit dem Titel Login issue für O'Neil. Beide verwenden dieselbe feste SQL-Struktur. Falls Python einen Einrückungs- oder Syntaxfehler meldet, stelle die oben gezeigte kurze Funktion wieder her; ändere weder Datenbank noch Aufrufprogramm.

Ablehnung und legitimes Verhalten prüfen

In diesem Schritt bestätigst du, dass die Korrektur gültige Namen erhält und verhindert, dass der angegebene Text die Suche erweitert.

Wiederhole die abfrageverändernde Eingabe:

python3 run_lookup.py "' OR 1=1 --"

Erwartet wird jetzt eine leere Liste tickets. SQLite sucht nach einem Eigentümer, der wörtlich so heißt wie dieser Text, und ein solcher existiert nicht. Es wurde keine Abfragestruktur hinzugefügt.

Prüfe einen gewöhnlichen nicht vorhandenen Eigentümer:

python3 run_lookup.py "Missing owner"

Erwartet wird eine weitere leere Liste. Ein fehlender Wert ist etwas anderes als eine defekte Anwendung: Gewöhnliche Namen und legitime Namen mit Apostroph liefern weiterhin ihre eigenen Tickets zurück.

Führe die bereitgestellte funktionale Regressionsprüfung mit derselben Aufrufform wie im Abhängigkeits-Lab aus:

bash test.sh

Erwartet wird:

PASS: ordinary, quoted, missing, and injection-like names.

Diese Prüfungen führen echte SQLite-Aufrufe aus und untersuchen Ergebniszeilen. Die Funktion zu entfernen, für jede Eingabe eine leere Liste zurückzugeben oder Daten zu löschen würde die legitimen Ergebnisse nicht erhalten. Auch eine Zeichen-Blacklist reicht nicht aus: Ein echter Eigentümer hat einen Apostroph im Namen. Die beabsichtigte Abwehr ist die Trennung von festem SQL-Text und gebundenen Werten.

Den korrigierten Quellcode erneut prüfen

In diesem Schritt kombinierst du das statische Ergebnis mit dem gerade getesteten tatsächlichen Suchverhalten.

course-code-scan lookup.py

Erwartet wird No findings under the fixed local rule. Der angegebene Name fließt nicht mehr in die SQL-Befehlszeichenfolge ein. Er wird als getrennter gebundener Wert übergeben.

Führe die Funktionsprüfung erneut aus:

bash test.sh

Erwartet wird dasselbe erfolgreiche Ergebnis. Scan und Verhaltenstest beantworten unterschiedliche Fragen: Der eine untersucht das anvisierte Codemuster; der andere prüft, dass echte Namen weiterhin funktionieren und spezielle Eingaben das Ergebnis nicht erweitern können.

Die Kursprüfung beobachtet auch die tatsächlichen SQLite-Argumente. Einen Scannerfund lediglich zu verstecken oder Anführungszeichen manuell zu ersetzen ist daher nicht die erforderliche Korrektur. Behalte die ursprüngliche Datenbank, die vorbereiteten Testdaten und die Verbindung, das Aufrufprogramm und die Funktionsschnittstelle bei. Eine schriftliche Untersuchungsdatei ist nicht nötig.

Dieses Lab hat ein kleines SQLite-Bindungsmuster und eine lokale SAST-Regel vermittelt. Eine vollständige Anwendungsprüfung würde auch andere Eingabequellen und Schwachstellenklassen berücksichtigen. Hier hast du das konkrete Risiko vom Eingangswert zur Abfrage entfernt und dabei die beabsichtigte Suche erhalten.

Zusammenfassung

Du hast einen angegebenen Namen durch eine kurze Python-Suchfunktion und eine SQL-Bedingung verfolgt, einen echten lokalen Semgrep-Fund mit dem Laufzeitverhalten verknüpft und die SQLite-Parameterbindung verwendet. Gewöhnliche Namen und Namen mit Apostrophen funktionieren weiterhin, während Eingaben, die die Abfrage verändern sollen, die Ergebnisse nicht erweitern können. Statische und funktionale Nachweise bestätigen die konkret korrigierte Zugriffsgrenze.