SQL-инъекции: как они работают и как защититься (практика)

SQL-инъекция — уязвимость из 90-х, которая до сих пор стабильно входит в OWASP Top 10 и регулярно фигурирует в реальных утечках баз данных. Причина проста: пока запрос собирается из кусков текста, в котором часть кусков контролирует пользователь, инъекция возможна. Все остальные «почему» — варианты этого одного.

Разберём механику, полную карту векторов (включая те, о которых забывают), защиту на уровне кода, драйвера и СУБД — и специфику WordPress, если вы пишете плагины или кастомные темы.

Важно. Все payload-примеры в статье — для понимания защиты. Не используйте их против чужих систем.


1. Механика на одном примере

Начнём с канонического «небезопасного» запроса:

PHP
<?php
// profile.php — найти пользователя по имени
$name = $_GET['name'];
$sql  = "SELECT id, name, email FROM users WHERE name = '" . $name . "'";
$res  = $pdo->query($sql);

При нормальном запросе (?name=Ivan) в базу уходит:

SQL
SELECT id, name, email FROM users WHERE name = 'Ivan'

При ?name=' OR '1'='1 — вот что реально собирается:

SQL
SELECT id, name, email FROM users WHERE name = '' OR '1'='1'

Пользовательский ввод не отделён от синтаксиса SQL: кавычка, которую «закрыл» программист, открыта символом из URL, а дальше идёт уже код запроса. СУБД выполняет его как инструкцию, а не как строку-данные.

Ключевая идея для понимания всего остального: SQL-инъекция — это не «символы ;, ', -- надо запретить». Это нарушение границы между кодом и данными. Отсюда и все способы защиты.


2. Карта видов SQL-инъекций

Не будем углубляться в эксплуатацию, но вам как защитнику важно понимать, какие вообще бывают каналы вывода. Это определяет, что именно вы должны уметь ловить в логах и как тестировать себя.

2.1 In-band (результат возвращается прямо)

  • Union-based. Атakующий подмешивает UNION SELECT …, чтобы в выдачу страницы легли чужие данные. Классический разведочный приём — ORDER BY 1,2,3,… до падения: так определяют число колонок запроса, с которым потом стыкуется UNION.
  • Error-based. Извлечение данных через текст ошибки СУБД (extractvalue(), updatexml() в MySQL, convert() в MSSQL). Медленнее, но работает там, где UNION не выводится в интерфейс.
  • Stacked queries. Две команды через ;. В MySQL через PDO::query() не работает (только одна команда на вызов), а вот в MSSQL/PostgreSQL через некоторые API — да. Поэтому «у меня MySQL, stacked-запросы мне не страшны» — верно лишь частично: без ; тоже можно извлечь всё, просто через UNION.

2.2 Inferential / Blind (ответ «не читается», но сервер на него реагирует)

  • Boolean-based. AND 1=1 vs AND 1=2 — страница стала другой, или изменилась длина ответа. По булеву признаку извлекают данные посимвольно: AND SUBSTRING((SELECT password FROM admins LIMIT 1),1,1)='a'.
  • Time-based. SLEEP(2) / pg_sleep(2) / WAITFOR DELAY '0:0:2'. Сработало с задержкой — условие истинно. Самый медленный, но обходит полную «слепоту» приложения. Именно этот класс ловится не фильтром, а таймаутами и rate limiting’ом.

Почему это важно вам. На тесте «а выводит ли сайт мои символы» вы увидите чистый сайт, а на самом деле там boolean-based SQLi. Поэтому «я посмотрел руками — всё ок» не является методом проверки; только статический анализ или тестер, который умеет слепые каналы.

2.3 Out-of-band

Данные выводятся через другой канал — DNS-запрос (LOAD_FILE с внешним URL, UTL_HTTP в Oracle), исходящий HTTP. Встречается реже, но в связке с привилегированным доступом к СУБД превращает чтение таблицы в RCE или данные, уходящие на сторону.

2.4 Second-order (отложенная инъекция)

Вредоносный ввод попадает в БД сегодня (например, при регистрации) и «выстреливает» позже, когда другой запрос использует сохранённое значение без экранирования — как часть WHERE login = '...' или ORDER BY .... Классические цели: логины, названия статусов, поля профилей, метки в wp_postmeta. Это самый «неприглядный» класс для аудита: код, который вставляет, не уязвим; уязвим тот, который читает.


3. Векторы, о которых забывают

Дальше — то, что реально находят в рабочих проектах. Наивная параметризация «везде, где вижу $_GET» это не закрывает.

3.1 Числовые параметры — «кавычек нет, значит безопасно»

PHP
// ТАК НЕЛЬЗЯ: кажется, что тут нечего «закрывать»
$id = $_GET['id'];
$sql = "SELECT * FROM posts WHERE id = " . $id;

id=1 OR 1=1 не содержит ни одной кавычки и ломает запрос в вашу пользу. Классическая ошибка, потому что «нечего экранировать — значит, не уязвимо».

3.2 Динамический ORDER BY — плейсхолдер не работает

Очень частый кейс в админках. Поле сортировки приходит от пользователя:

PHP
// ТАК НЕЛЬЗЯ
$sort = $_GET['sort'] ?? 'title';
$dir  = $_GET['dir'] ?? 'asc';
$sql  = "SELECT * FROM posts ORDER BY {$sort} {$dir}";

// И «исправленный вариант» — тоже нельзя, плейсхолдеры не работают с идентификаторами:
$sql  = "SELECT * FROM posts ORDER BY ? ?"; // ← SQL-синтаксис запрещает
$stmt = $pdo->prepare($sql);

Имя столбца и направление сортировки — это структура запроса, а не данные, поэтому ? их не подставит. Единственный правильный путь — белый список:

PHP
$allowed = [
    'title'  => 'post_title',
    'date'   => 'post_date',
    'author' => 'post_author',
];
$sort = $allowed[$_GET['sort'] ?? ''] ?? 'post_title'; // значение из БЕЛЫХ СПИСКОВ, не из ввода
$dir  = (strtolower($_GET['dir'] ?? '') === 'desc') ? 'DESC' : 'ASC';

$stmt = $pdo->prepare("SELECT ID, post_title FROM wp_posts ORDER BY {$sort} {$dir} LIMIT ?");
$stmt->bindValue(1, $limit, PDO::PARAM_INT); // значение остаётся параметром
$stmt->execute();

Значения в $sort и $dir берутся из вашего кода, а не из $_GET. В БД уходит только то, что там написано.

3.3 IN (…) со списком переменной длины

PHP
// ТАК НЕЛЬЗЯ
$ids = explode(',', $_GET['ids']);
$sql = "SELECT * FROM users WHERE id IN (" . implode(',', $ids) . ")";

// НЕПРАВИЛЬНОЕ «исправление» — тоже нельзя: ? подставляет ОДНО значение, а не список
$sql = "SELECT * FROM users WHERE id IN (?)";
$pdo->prepare($sql)->execute([$_GET['ids']]);

Корректный способ — собрать нужное число плейсхолдеров:

PHP
$ids = array_values(array_filter(array_map('intval', explode(',', $_GET['ids'] ?? ''))));

if (!$ids) {
    // fail closed: пустой список = ничего не отдаём, а не «все»
    return [];
}

$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
$stmt->execute($ids);

Каждый ID — отдельный параметр и приводится к int. Если вдруг $ids пустой, в БД не уходит IN () (синтаксическая ошибка) и не уходит «без WHERE» — это классический fail-open.

3.4 Имена таблиц и колонок из переменной

Аналогично ORDER BY — идентификаторы нельзя плейсхолдерить. Пример: мультитенант, таблица выбирается по имени tenant’а. Единственный корректный путь — маппинг:

PHP
$tables = [
    'acme'   => 'acme_orders',
    'globex' => 'globex_orders',
];
$tenant = strtolower($_GET['tenant'] ?? '');
if (!isset($tables[$tenant])) {
    http_response_code(400);
    exit('Неизвестный tenant');
}
$table = $tables[$tenant]; // строка из КОНСТАНТЫ, не из ввода
$stmt = $pdo->prepare("SELECT * FROM {$table} WHERE user_id = ?");
$stmt->execute([$userId]);

Обёртывать имя в обратные кавычки (`...`) — не защита: обратные кавычки можно закрыть, инъекция идёт дальше.

3.5 LIKE и спецсимволы шаблона

Даже при параметризованном LIKE ? ввод вроде % или _ превращается в «найти всё» или «найти любой символ». Это логическая инъекция шаблона, а не синтаксиса SQL. Закрывается ESCAPE:

PHP
$term = trim($_GET['q'] ?? '');
// Экранируем спецсимволы шаблона, НЕ кавычки
$like = addcslashes($term, '\\%_');

$stmt = $pdo->prepare(
    "SELECT * FROM posts WHERE post_title LIKE ? ESCAPE '\\' AND post_status = 'publish' LIMIT 30"
);
$stmt->execute(['%' . $like . '%']);

Побочный эффект: без addcslashes пользователь, ищущий 100%, получит «всё, что содержит 100 и что угодно дальше». Часто это ещё и DoS-вектор: LIKE '%%%…%%%' по большой таблице — медленный запрос.

3.6 JSON/XML-поля, которые «внутри строки»

В MySQL 8 есть JSON_EXTRACT и оператор ->>. Если вы строите запрос, подставляя путь к JSON-полю ($.user.name) — это тоже идентификатор, а не значение. Плейсхолдеры не работают. Белый список путей — да.

3.7 Кодировки и emulate prepares

Если приложение использует эмулируемые prepared statements PDO (включены по умолчанию для MySQL-драйвера) и неявно переключает сессионную кодировку (SET NAMES 'gbk' вместо mysql_set_charset / DSN charset=…), исторически возможна инъекция через мультитейбл-последовательности байт: спецсимвол склеивается с \ и «съедает» обратный слэш, который эмулятор поставил перед кавычкой.

Что важно в 2026: на современных UTF-8 setup’ах этот конкретный вектор не работает (UTF-8 не принимает «одиночные» байты-escape). Но сам подход — «PDO эмулирует запрос на клиенте» — это всё ещё отдельный слой, где ошибка конфигурации приводит к дыре. Отсюда практический совет в разделе защиты: выключать эмуляцию и задавать charset через DSN.


4. Защита: три уровня, а не один

4.1 Уровень кода — параметризация везде

PHP
<?php
declare(strict_types=1);

$dsn = 'mysql:host=localhost;dbname=app;charset=utf8mb4';

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // ошибки — в логи, не в браузер
    PDO::ATTR_EMULATE_PREPARES   => false,                   // настоящие prepared statements сервера
    PDO::ATTR_STRINGIFY_FETCHES  => false,                   // нативные типы (int вместо "1")
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

// Значение идёт параметром — СУБД никогда не видит его как код
$stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE name = ?');
$stmt->execute([$_GET['name']]);   // ввод = данные

Почему именно так, построчно:

  • charset=utf8mb4 в DSN. Кодировка задаётся при подключении, а не отдельным SET NAMES. Иначе между «соединение открыто» и «charset выставлен» есть окно, в котором сессия работает на дефолте сервера.
  • ATTR_EMULATE_PREPARES => false. При эмуляции PDO сам подставляет параметры в текст запроса на клиенте (это тот слой, о котором речь в 3.7). С false запрос уходит серверу как COM_STMT_PREPARE, а данные — отдельно как COM_STMT_EXECUTE. Разделение кода и данных обеспечивает сам сервер. Важно: выключенная эмуляция также делает типизацию строже — для PDO::PARAM_INT число нужно передавать числом (или использовать bindValue(..., PDO::PARAM_INT)).
  • ERRMODE_EXCEPTION. В режиме SILENT (дефолт до PHP 8) ошибка SQL молча возвращает false, а вы падаете дальше и видите «непонятный пустой результат». В режиме WARNING/EXCEPTION вы сразу узнаете о проблеме. На проде ошибки в браузер не должны выводиться (см. OWASP A02: display_errors = Off).

4.2 Уровень типов — приведите к ожидаемому типу

Даже с параметризацией приводите число к числу, email — к email, UUID — к UUID. Это защита от логики, а не синтаксиса: id="1 OR 1=1" с (int) становится 1.

PHP
$id  = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$mail = filter_input(INPUT_GET, 'email', FILTER_VALIDATE_EMAIL);

if ($id === false || $id === null) {
    http_response_code(400);
    exit('Некорректный id');
}
if ($mail === false) {
    http_response_code(400);
    exit('Некорректный email');
}

Типизация ловит не только инъекцию, но и весь класс «странный ввод, который прошёл, но сделал не то» (?id=999999999999999999999, ?id=-1, ?id[]=1).

4.3 Уровень СУБД — привилегии

Самый недооценённый уровень. Даже если в один прекрасный день код где-то «просочится», взлом упрётся в права.

-- Пользователь приложения должен иметь доступ ровно к тем операциям, что делает код
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'10.0.0.%';

-- Явно НЕ даём:
--   FILE          → LOAD_FILE() / INTO OUTFILE: чтение файлов и выгрузка дампа на диск
--   SUPER         → SET GLOBAL, доступ к другим БД, включение general_log на запись
--   DROP / ALTER  → уничтожение или модификация схемы
--   GRANT OPTION  → распространение своих прав
--   PROCESS       → сырые запросы других клиентов

FLUSH PRIVILEGES;

Практические ограничения, которые стоит выставить:

  • secure_file_priv (my.cnf / 8.4 default '') — запрещает INTO OUTFILE/LOAD_FILE вообще, кроме явно указанной директории. Даже если привилегия FILE по ошибке дана, без secure_file_priv она бесполезна.
  • max_execution_time (мс, MySQL 8.0+ / 5.7) или statement_timeout (PostgreSQL) — убивает time-based-инъекцию и защищает от «тяжёлого» запроса.
  • read_only на реплике, отдельный БД-юзер на бэкенд-джоб — меньше поверхность.

4.4 Уровень приложения — фильтрация вывода не отменяет параметризацию

Важно не перепутать:

  • htmlspecialchars / esc_sql решают задачи XSS и (частично) экранирования. Они не защищают SQL, если вы всё равно конкатенацией собираете запрос: любой фильтр когда-нибудь ошибётся, забудется, или будет пропущен в одном месте.
  • WAF — полезный defense-in-depth: отсекает типовые payload’ы, логирует попытки, замедляет ботов. Но не заменяет параметризацию: обход фильтров — это отдельная дисциплина, и каждый год кто-то её успешно проходит на живых сайтах.

5. Специфика WordPress: ловушки $wpdb->prepare()

Если вы пишете плагины или кастомные запросы в теме, wpdb->prepare() — основной инструмент. Но у него есть особенности, о которых регулярно спотыкаются.

5.1 %s ≠ ?, а %d строже, чем кажется

PHP
// Так правильно: плейсхолдеры WordPress (%s/%d/%f), НЕ «?»
$row = $wpdb->get_row(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s",
        $slug,
        'page'
    )
);

// %d приведёт значение к int — если там строка "1 OR 1=1", останется 0
$uid = $wpdb->get_row(
    $wpdb->prepare("SELECT * FROM {$wpdb->users} WHERE ID = %d", $id)
);

Важное отличие от PDO: wpdb->prepare всегда эмулирует подстановку на PHP-стороне (WordPress не использует настоящие prepared statements сервера). Именно поэтому правила использования строже.

5.2 Массив в %s для IN (…) — не работает «как ожидается»

PHP
// ТАК НЕЛЬЗЯ: передадим массив в один %s — в SQL попадёт Array, а не список значений
$ids = [1, 2, 3];
$wpdb->prepare("SELECT * FROM {$wpdb->posts} WHERE ID IN (%s)", $ids);

// ПРАВИЛЬНО: собрать плейсхолдеры под количество элементов
$ids = array_map('intval', (array) ($_GET['ids'] ?? ''));
$ids = array_values(array_filter($ids));

if (!$ids) {
    return []; // fail closed
}

$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$rows = $wpdb->get_results(
    $wpdb->prepare("SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ({$placeholders})", ...$ids)
);

Обратите внимание на ...$ids (spread) — WordPress 6.0+ позволяет передать список параметров в prepare() как отдельные аргументы функции.

5.3 Имена таблиц и ORDER BY — prepare() не спасает

PHP
// ТАК НЕЛЬЗЯ: плейсхолдер не подставит имя колонки
$wpdb->prepare("SELECT * FROM {$wpdb->posts} ORDER BY %s", $_GET['sort']);

// ПРАВИЛЬНО — белый список (см. 3.2); esc_sql не нужен (мы не вставляем ввод)
$sortMap = ['title' => 'post_title', 'date' => 'post_date', 'menu' => 'menu_order'];
$sort    = $sortMap[$_GET['sort'] ?? ''] ?? 'post_date';
$dir     = strtoupper($_GET['dir'] ?? '') === 'ASC' ? 'ASC' : 'DESC';

$sql = $wpdb->prepare(
    "SELECT ID, post_title FROM {$wpdb->posts}
     WHERE post_status = %s AND post_type = 'page'
     ORDER BY {$sort} {$dir}
     LIMIT %d OFFSET %d",
    'publish',
    $perPage,
    $offset
);

5.4 Мета-запросы: ловушка сериализованного значения

meta_query WordPress сам экранирует значения. Но meta_key (имя поля) в meta_query идёт в SQL буквально, и если вы подставляете туда $_GET['field'] — это идентификатор, снова белый список.

5.5 esc_sql, sanitize_text_field, wp_unslash — что есть что

  • wp_unslash() — обязателен перед prepare(). WordPress автоматически «заслэшит» суперглобалы (magic-quotes-подобная эмуляция сохранена ради совместимости), и prepare() этого не снимет: O\'Brien уйдёт в запрос как O\'Brien с буквальным слэшем.
  • esc_sql() — обёртка над wpdb->escape(), эмулирует экранирование кавычек. Использовать не стоит: там, где нужен prepare(), esc_sql() создаёт ложное ощущение безопасности; а там, где это идентификатор, он не поможет.
  • sanitize_text_field() — чистит ввод от HTML/лишних пробелов, не является защитой от SQLi.
PHP
$slug = sanitize_title(wp_unslash($_GET['slug'] ?? ''));
// prepare + wp_unslash перед ним. Правильная пара.
$page = $wpdb->get_row(
    $wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'page'",
        $slug
    )
);

6. Как самому найти свои SQLi

«Я посмотрел руками» не работает (см. 2.2 — слепые каналы). Работают три подхода, и правильно комбинировать.

6.1 Статический анализ — искать конкатенации в SQL

Самое дешёвое и очень точное. Все статические анализаторы PHP имеют детекторы, построенные на поиске: строка-шаблон SQL + конкатенация с переменной из внешнего ввода.

  • Psalm с --taint-analysis — именно строит граф «источник (super-global) → sink (SQL-функция)» и показывает конкретные цепочки.
  • PHPStan + security-плагины ищут те же паттерны.
  • Semgrep — набор правил по кодовой базе: mysqli_query с конкатенацией, ->query( с . или {, unserialize и т. п.
# Taint-анализ Psalm: ищет потоки «внешний ввод → SQL-вызов»
composer require --dev vimeo/psalm
vendor/bin/psalm --taint-analysis src/

# Быстрая эвристика grep по своей кодовой базе: конкатенации в SQL
grep -rn -E '(->query\(|mysqli_query\().*\$(GET|POST|REQUEST|_COOKIE)' src/

# WordPress: grep «неполных» prepare — строка с плейсхолдером без параметров после
grep -rn -E '\$wpdb->prepare\([^,]*%[sdf][^,]*\)' wp-content/plugins/

6.2 Динамический аудит на своём стенде

sqlmap — стандарт, но запускается только против своей же копии сайта на локальном стенде, а не против живого продакшена (это и технически опасно — нагрузка, и юридически — несанкционированный доступ).

Практический маршрут:

  1. Развернуть копию сайта на dev-домене с тестовыми данными.
  2. Прогнать sqlmap -u "https://dev.example.com/?id=1" --level=3 --risk=2 --batch по всем GET/POST-параметрам.
  3. Найти «подтверждённые» векторы → исправить → прогнать заново.

Не используйте --os-shell/--file-write: они реально пишут файлы и исполняют команды на стенде. Это нужно только чтобы убедиться, что вы вообще можете это сделать — а вы уже знаете, если нашли инъекцию.

6.3 Логирование и детект на проде

  • General query log в MySQL на проде включать нельзя (падение производительности); вместо этого — slow query log с порогом 2 секунды и алертом. Time-based-инъекции падают туда.
  • Error log СУБД: синтаксические ошибки от чужих запросов видны как SQL syntax; check the manual that corresponds to your MySQL server version.
  • Прикладной лог: на каждый ответ с признаком синтаксической ошибки (SQLSTATE[42000]) — алерт по IP.
  • CDN/WAF-логи (Cloudflare, nginx mod_security/Coraza): аномальные паттерны в query string.

6.4. Инструмент под задачу

Собрать под свой запрос чистый параметризованный prepare() (с %s/%d вместо «?», с раскруткой массива для IN (…) и явной whitelist-проверкой ORDER BY) проще по живому примеру, чем по таблице правил. Попробуйте инструмент прямо здесь, на своих данных:

ИИ SQL-Генератор
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

7. Чек-лист

  • Все запросы со значениями от пользователя идут через prepare() + execute([...]), не конкатенацию.
  • Для PDO: ATTR_EMULATE_PREPARES=false, ERRMODE_EXCEPTION, charset=utf8mb4 в DSN.
  • Для WordPress: параметры идут в prepare() после wp_unslash(); esc_sql() нигде не используется как «главная защита».
  • Динамические ORDER BY, имена таблиц/колонок, направления сортировки — берутся из белого списка, а не из ввода.
  • Списки в IN (…), VALUES (…), LIMIT/OFFSET — параметризованы; при пустом наборе происходит отказ, а не «выбрать все».
  • В LIKE спецсимволы % и _ экранированы (addcslashes + ESCAPE '\\').
  • Пользователь приложения БД: SELECT/INSERT/UPDATE/DELETE на своей схеме, без FILE/SUPER/DROP/GRANT OPTION.
  • secure_file_priv выставлена, max_execution_time ограничен.
  • На проде display_errors = Off, log_errors = On; сообщения об ошибках БД не уходят в HTTP-ответ.
  • Статический анализ (Psalm/PHPStan/Semgrep) настроен в CI; sqlmap-прогон по dev-копии — регулярный (например, раз в квартал).
  • Логи БД и приложения мониторятся; есть алерт на синтаксические SQL-ошибки и аномально долгие запросы.

8. Источники

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.