SQL-инъекция — уязвимость из 90-х, которая до сих пор стабильно входит в OWASP Top 10 и регулярно фигурирует в реальных утечках баз данных. Причина проста: пока запрос собирается из кусков текста, в котором часть кусков контролирует пользователь, инъекция возможна. Все остальные «почему» — варианты этого одного.
Разберём механику, полную карту векторов (включая те, о которых забывают), защиту на уровне кода, драйвера и СУБД — и специфику WordPress, если вы пишете плагины или кастомные темы.
Важно. Все payload-примеры в статье — для понимания защиты. Не используйте их против чужих систем.
1. Механика на одном примере
Начнём с канонического «небезопасного» запроса:
<?php
// profile.php — найти пользователя по имени
$name = $_GET['name'];
$sql = "SELECT id, name, email FROM users WHERE name = '" . $name . "'";
$res = $pdo->query($sql);При нормальном запросе (?name=Ivan) в базу уходит:
SELECT id, name, email FROM users WHERE name = 'Ivan'При ?name=' OR '1'='1 — вот что реально собирается:
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=1vsAND 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 Числовые параметры — «кавычек нет, значит безопасно»
// ТАК НЕЛЬЗЯ: кажется, что тут нечего «закрывать»
$id = $_GET['id'];
$sql = "SELECT * FROM posts WHERE id = " . $id;id=1 OR 1=1 не содержит ни одной кавычки и ломает запрос в вашу пользу. Классическая ошибка, потому что «нечего экранировать — значит, не уязвимо».
3.2 Динамический ORDER BY — плейсхолдер не работает
Очень частый кейс в админках. Поле сортировки приходит от пользователя:
// ТАК НЕЛЬЗЯ
$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);Имя столбца и направление сортировки — это структура запроса, а не данные, поэтому ? их не подставит. Единственный правильный путь — белый список:
$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 (…) со списком переменной длины
// ТАК НЕЛЬЗЯ
$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']]);Корректный способ — собрать нужное число плейсхолдеров:
$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’а. Единственный корректный путь — маппинг:
$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:
$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
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.
$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 строже, чем кажется
// Так правильно: плейсхолдеры 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 (…) — не работает «как ожидается»
// ТАК НЕЛЬЗЯ: передадим массив в один %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() не спасает
// ТАК НЕЛЬЗЯ: плейсхолдер не подставит имя колонки
$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.
$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 — стандарт, но запускается только против своей же копии сайта на локальном стенде, а не против живого продакшена (это и технически опасно — нагрузка, и юридически — несанкционированный доступ).
Практический маршрут:
- Развернуть копию сайта на dev-домене с тестовыми данными.
- Прогнать
sqlmap -u "https://dev.example.com/?id=1" --level=3 --risk=2 --batchпо всем GET/POST-параметрам. - Найти «подтверждённые» векторы → исправить → прогнать заново.
Не используйте
--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) проще по живому примеру, чем по таблице правил. Попробуйте инструмент прямо здесь, на своих данных:
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. Источники
- OWASP — SQL Injection Prevention Cheat Sheet
- OWASP Top 10:2025 — A05:2025 Injection
- PHP — PDO prepared statements, PDO::ATTR_EMULATE_PREPARES
- WordPress — Class Reference/WPDB->prepare
- MySQL — Prepared Statements, secure_file_priv
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
