Skip to content

How-to: SQL Injection Defence

FT reference: FT264 (NENE2-FT/injectionlog) — SQL injection defence: parameterized queries, LIKE injection, ORDER BY allowlist ATK: FT264 — cracker-mindset attack test (ATK-01 through ATK-12)

Demonstrates the three main SQL injection vectors in a PHP API — value injection, LIKE wildcard injection, and ORDER BY column injection — and the correct defence for each. Includes a full cracker-mindset attack assessment.


Routes

MethodPathDescription
GET/productsList/search products (filterable, sorted)
POST/productsCreate a product
GET/products/{id}Get a single product
DELETE/products/{id}Delete a product

Schema

sql
CREATE TABLE IF NOT EXISTS products (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT    NOT NULL,
    category    TEXT    NOT NULL,
    price       REAL    NOT NULL DEFAULT 0.0,
    description TEXT    NOT NULL DEFAULT ''
);

The three SQL injection surfaces

1. Value injection: parameterized queries

php
// ❌ String interpolation — injectable
$rows = $db->fetchAll("SELECT * FROM products WHERE id = {$id}");

// ✅ Parameterized — the driver escapes all values
$rows = $db->fetchAll('SELECT * FROM products WHERE id = ?', [$id]);

PDO's ? placeholder binds the value as a typed parameter. The value is never interpolated into the SQL string. An attacker who sends id = "1; DROP TABLE products; --" has their entire input stored as a literal string binding — the SQL is not modified.

2. LIKE wildcard injection: parameterized wildcards

php
// ❌ Interpolated LIKE — injectable AND wildcard-escaped
$rows = $db->fetchAll("SELECT * FROM products WHERE name LIKE '%{$q}%'");

// ✅ Parameterized wildcard — the ? value is bound after the || concatenation
$rows = $db->fetchAll(
    "SELECT * FROM products WHERE name LIKE '%' || ? || '%' OR description LIKE '%' || ? || '%'",
    [$q, $q],
);

'%' || ? || '%' is standard SQL string concatenation (SQLite, PostgreSQL). The ? value is bound as a parameter — the % wildcards are literals in the SQL string, not from user input.

LIKE metacharacter escape: % and _ within the user input $q are NOT escaped in this implementation. A search for % would match everything. For production, escape LIKE metacharacters:

php
$escaped = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $q);
$rows = $db->fetchAll("... WHERE name LIKE '%' || ? || '%' ESCAPE '\\'", [$escaped, $escaped]);

3. ORDER BY injection: column allowlist

php
private const array ALLOWED_SORT_FIELDS = ['id', 'name', 'category', 'price'];

public function search(string $query = '', string $sortField = 'id', string $sortDir = 'asc'): array
{
    if (!in_array($sortField, self::ALLOWED_SORT_FIELDS, true)) {
        throw new InvalidSortFieldException("Invalid sort field: {$sortField}");
    }

    $sortDir    = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
    $sortClause = $sortField . ' ' . $sortDir;   // safe: allowlisted column + whitelisted direction

    $rows = $db->fetchAll(
        "SELECT * FROM products ORDER BY {$sortClause}",
    );
}

ORDER BY cannot use parameterized placeholders — the column name must be interpolated. The correct defence is an explicit allowlist: only values in ALLOWED_SORT_FIELDS may appear in the SQL string. Any other value throws an exception (400 in the controller).

sortDir is mapped to exactly 'ASC' or 'DESC' — user input is never directly interpolated.


ATK — Cracker-mindset attack test (FT264)

ATK-01 — Classic SELECT injection via GET parameter

Attack: Inject SQL via the search query ?q=' OR '1'='1.

GET /products?q=' OR '1'='1

Observed: $q is bound as a ? parameter in LIKE '%' || ? || '%'. The entire string ' OR '1'='1 is treated as a literal text value to match. No additional rows are returned.

Verdict: BLOCKED — parameterized LIKE prevents value injection.


Attack: Inject a destructive statement.

GET /products?q='; DROP TABLE products; --

Observed: The payload is bound as a LIKE pattern. '; DROP TABLE products; -- is searched as literal text. The table is not dropped.

Verdict: BLOCKED — parameterized queries cannot execute injected statements.


ATK-03 — ORDER BY column injection: arbitrary column

Attack: Inject an unrecognized sort column.

GET /products?sort=password

Observed: in_array('password', self::ALLOWED_SORT_FIELDS, true) returns false. InvalidSortFieldException is thrown. The controller catches it and returns 400.

Verdict: BLOCKED — column allowlist rejects unknown column names.


ATK-04 — ORDER BY injection: subquery injection

Attack: Inject a subquery as the sort column.

GET /products?sort=(SELECT%20name%20FROM%20users%20LIMIT%201)

Observed: The decoded value (SELECT name FROM users LIMIT 1) is not in ALLOWED_SORT_FIELDS. InvalidSortFieldException thrown. 400 returned.

Verdict: BLOCKED — allowlist rejects any value not in the known column list, including subqueries.


ATK-05 — ORDER BY injection: direction tampering

Attack: Inject SQL via the sort direction parameter.

GET /products?order=DESC;%20DROP%20TABLE%20products;--

Observed: strtolower($sortDir) === 'desc' is false for the injected value. The direction falls through to 'ASC'. The injected SQL is never interpolated. 200 returned with products ordered ASC.

Verdict: BLOCKED — direction is mapped to exactly 'ASC' or 'DESC', never interpolated.


ATK-06 — UNION injection via search query

Attack: Inject a UNION SELECT to exfiltrate data.

GET /products?q=' UNION SELECT id,name,email,password,'' FROM users --

Observed: The full injection string is bound as a LIKE parameter value. The UNION SELECT is searched as literal text in the name and description columns. No user data is returned.

Verdict: BLOCKED — parameterized query prevents UNION injection.


ATK-07 — ID injection via path parameter

Attack: Inject SQL via the path parameter.

GET /products/1;%20DROP%20TABLE%20products;

Observed: The path parameter {id} is cast to int by (int) $params['id']. The SQL becomes WHERE id = 1 — the injection suffix is truncated by the cast. The table is not dropped.

Verdict: BLOCKED(int) cast truncates at the first non-digit character.


Attack: Leak data via boolean conditions.

GET /products?q=' AND '1'='1
GET /products?q=' AND '1'='2

Observed: Both strings are bound as LIKE parameters. Both return products whose name or description contains the literal text ' AND '1'='1. Neither query modifies the SQL WHERE logic. Both return the same (empty) result set.

Verdict: BLOCKED — parameterized binding prevents boolean injection.


ATK-09 — Second-order injection: stored payload retrieved later

Attack: Create a product with a name containing SQL, then search for all products.

json
POST /products {"name": "'; DROP TABLE products; --", "category": "test", "price": 1}
GET /products

Observed: The INSERT uses parameterized ? — the injection payload is stored as literal text. The SELECT * and LIKE queries also use parameterized queries. The payload is returned as a string value, never executed as SQL.

Verdict: BLOCKED — all read and write paths use parameterized queries.


Attack: Send ?q=% to match all products, bypassing a intended empty-search default.

GET /products?q=%25   (URL-decoded: %)

Observed: $q = '%' is bound as a LIKE parameter. LIKE '%' || '%' || '%' = LIKE '%%%' which matches every row. All products are returned.

Verdict: EXPOSED% and _ in user input are not escaped. A search for % matches everything; a search for _ matches any single character. Escape LIKE metacharacters or document the behaviour as intentional.


ATK-11 — NULL byte injection

Attack: Embed a null byte in the search query.

GET /products?q=widget%00extra

Observed: PHP's ? binding passes the raw string including the null byte to SQLite's parameterized query. SQLite treats the null byte as part of the string. LIKE '%widget\0extra%' does not match normal product names. No injection occurs.

Verdict: BLOCKED — parameterized queries handle null bytes as literal string content.


ATK-12 — Stacked queries (multi-statement injection)

Attack: Inject a second statement after a semicolon.

GET /products?q=test'; INSERT INTO products VALUES (99,'hacked','x',0,''); --

Observed: PDO executes only one statement per query()/prepare() call — stacked queries are not supported by default. Even if PDO allowed multiple statements, the value is bound as a parameter (not interpolated). The injected INSERT is stored as literal LIKE search text.

Verdict: BLOCKED — parameterized binding + PDO single-statement mode prevent stacked queries.


ATK summary

#Attack vectorVerdict
ATK-01Classic SELECT injection via ?q=BLOCKED
ATK-02DROP TABLE via searchBLOCKED
ATK-03ORDER BY unknown columnBLOCKED
ATK-04ORDER BY subquery injectionBLOCKED
ATK-05Sort direction injectionBLOCKED
ATK-06UNION SELECT via searchBLOCKED
ATK-07ID injection via path paramBLOCKED
ATK-08Boolean-based blind injectionBLOCKED
ATK-09Second-order injectionBLOCKED
ATK-10LIKE metacharacter flood (%)EXPOSED
ATK-11Null byte injectionBLOCKED
ATK-12Stacked queriesBLOCKED

Real vulnerabilities to fix before production:

  1. ATK-10 — Escape LIKE metacharacters (%, _, \) before binding to prevent wildcard flooding.

Defence summary

SurfaceVulnerable patternSafe pattern
Value in WHEREWHERE id = {$id}WHERE id = ? with [$id]
LIKE searchWHERE name LIKE '%{$q}%'WHERE name LIKE '%' || ? || '%'
ORDER BY columnORDER BY {$sortField}in_array($sortField, ALLOWED, true) + interpolate
ORDER BY directionORDER BY col {$dir}$dir === 'desc' ? 'DESC' : 'ASC'
Path parameter IDWHERE id = {$id}(int) $id + parameterized

Released under the MIT License.