These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecuritySQL Injection

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_string followed 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; Doctrine createNativeQuery; Eloquent DB::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=php

For 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=1
  • 1 OR id=1
  • 1 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 when set_charset('utf8mb4') is missing (SET NAMES utf8 is 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:

  1. Placeholders vs concatenation? / :name placeholders for all values. Anything concatenated is a flag.
  2. Column / table / ORDER BY — these can’t be parameter-bound. Must be allowlisted (match ($_GET['sort']) { 'name' => 'name', ... }).
  3. mysqli_real_escape_string — is the escaped value enclosed in quotes in the SQL? Without quotes, escaping is irrelevant.
  4. PDO::ATTR_EMULATE_PREPARES — set false on connection. Otherwise PDO assembles the query string client-side and you lose much of the defense.
  5. Connection charset — set via $pdo->setAttribute(PDO::MYSQL_ATTR_INIT_COMMAND, 'SET NAMES utf8mb4') or DSN charset=utf8mb4. Without this, multi-byte charset injection is reachable on MySQL.
  6. Second-order — user input stored verbatim then re-used in a concatenated query later. Trace input down through writes and back up through reads.
  7. 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.