SQL Injection (PHP code review)
For black-box exploitation, see SQL Injection Basics. This note is the code review companion — what to grep for, what to look for in each hit, and the PHP-specific footguns.
Why
Same root cause everywhere: query strings concatenated with user input instead of using parameterised statements. PHP-specific ways to get it wrong:
mysqli_real_escape_stringfollowed by unquoted concatenation is not safe — escaping only neutralises quotes, so a numeric column injection (OR 1=1) still works without any quote chars.PDO::ATTR_EMULATE_PREPARES = true(the default on the MySQL driver in some versions) reverts to client-side parameter expansion. Different bug surface — second-order escaping issues.- ORM “raw query” escape hatches drop you straight back to manual
placeholders: Laravel
DB::raw,whereRaw,orderByRaw,selectRaw,havingRaw; DoctrinecreateNativeQuery; EloquentDB::statement. - ORDER BY / column names / table names can’t be bound as parameters — attempting placeholders there causes the query to fail, so devs concatenate “just for the sort column” and ship it.
Search patterns
# Raw query builders
rg -n '->query\(|mysqli_query\(|mysql_query\(|->exec\(' --type=php
# Prepares with concatenation (the dangerous form)
rg -n 'prepare\(.*\$' --type=php
rg -n 'prepare\(\s*[''"][^''"]*\$' --type=php # interpolation inside prepare string
# escape_string used (often a smell — proper code uses placeholders)
rg -n 'real_escape_string\(|addslashes\(' --type=php
# Laravel / Eloquent raw
rg -n 'DB::raw\(|whereRaw\(|orderByRaw\(|selectRaw\(|havingRaw\(|DB::statement\(' --type=php
# Doctrine raw
rg -n 'createQuery\(.*\$|createNativeQuery\(.*\$' --type=php
rg -n '->setParameter\(' --type=php # cross-reference: did they bind?
# WordPress
rg -n '\$wpdb->(query|get_results|get_row|get_var|get_col)\s*\(' --type=phpFor prepared statements specifically, the audit question isn’t “is
prepare called” — it’s “is the SQL string built from user input
before being passed to prepare?” Grep the prepare’s first argument.
Test inputs
Identification:
'— break syntax (watch for SQL errors in response, in logs)"— same\'— escape behaviour test''— same as'\''in some contexts;— statement terminator
Boolean / UNION:
' OR 1=1 --(trailing space matters for MySQL)' OR '1'='1' UNION SELECT NULL--(widen NULLs to column count)' UNION SELECT 1,2,3,4--(column-count walking)' UNION SELECT NULL,table_name FROM information_schema.tables--
Time-based (when output isn’t reflected):
- MySQL:
' OR SLEEP(5)-- - MySQL:
' AND (SELECT 1 FROM (SELECT SLEEP(5))a)-- - PostgreSQL:
'; SELECT pg_sleep(5)-- - MSSQL:
'; WAITFOR DELAY '0:0:5'-- - SQLite:
' AND randomblob(100000000)--(CPU-based)
Numeric column (no quotes needed — relevant when real_escape_string is
used without quoting):
1 OR 1=11 OR id=11 UNION SELECT version()
ORDER BY / LIMIT injection (placeholders don’t work there):
1; DROP TABLE users--(some drivers allow stacked queries)(CASE WHEN (1=1) THEN id ELSE name END)1, (SELECT … )— when LIMIT takes two args
Encoding / charset:
0xC0%27— multi-byte charset confusion whenset_charset('utf8mb4')is missing (SET NAMES utf8is the legacy bug)
Second-order:
- Register username
admin'--, log in later, see if your username breaks a downstream query.
Audit focus
For every query construction:
- Placeholders vs concatenation —
?/:nameplaceholders for all values. Anything concatenated is a flag. - Column / table / ORDER BY — these can’t be parameter-bound. Must
be allowlisted (
match ($_GET['sort']) { 'name' => 'name', ... }). mysqli_real_escape_string— is the escaped value enclosed in quotes in the SQL? Without quotes, escaping is irrelevant.PDO::ATTR_EMULATE_PREPARES— set false on connection. Otherwise PDO assembles the query string client-side and you lose much of the defense.- Connection charset — set via
$pdo->setAttribute(PDO::MYSQL_ATTR_INIT_COMMAND, 'SET NAMES utf8mb4')or DSNcharset=utf8mb4. Without this, multi-byte charset injection is reachable on MySQL. - Second-order — user input stored verbatim then re-used in a concatenated query later. Trace input down through writes and back up through reads.
- Multi-query /
mysqli_multi_query— should not be reachable from user input.
ORM raw query helpers (DB::raw, whereRaw) are an
invisible-to-grep risk because they often appear in subqueries deep
inside otherwise-fine Eloquent chains. Grep the entire codebase
including vendor packages — third-party packages introducing raw
queries are common.
Fix
PDO (correct shape):
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);
$rows = $stmt->fetchAll();mysqli (positional):
$stmt = $mysqli->prepare("SELECT * FROM users WHERE email = ?");
$stmt->bind_param('s', $email);
$stmt->execute();For sort columns or other identifier positions — allowlist:
$col = match ($_GET['sort'] ?? 'created') {
'name' => 'name',
'email' => 'email',
default => 'created_at',
};
$dir = ($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM users ORDER BY $col $dir";Never interpolate; always pick from a known set.
Related
- SQL Injection Basics — black-box exploitation
- intval Bypass — common SQLi precursor
- Common Web Payloads