Введение: вопрос, который мучает каждого PHP-разработчика
Если вы когда-нибудь писали SQL-запросы в PHP, то наверняка слышали мантру: "Используй PDO и prepared statements — и будешь в безопасности". Но так ли это на самом деле? На Stack Overflow вопрос "Are PDO prepared statements sufficient to prevent SQL injection?" собрал тысячи просмотров и десятки жарких обсуждений. И не зря.
В этой статье мы не просто дадим короткий ответ "да" или "нет". Мы залезем под капот PDO, разберем, как именно работают подготовленные запросы, и выясним, в каких случаях даже PDO может вас подвести. Готовы? Поехали.
Что такое SQL-инъекция и почему она страшна?
Прежде чем говорить о защите, давайте вспомним врага в лицо. SQL-инъекция — это атака, при которой злоумышленник "впрыскивает" вредоносный SQL-код через входные данные. Классика:
// Плохой код: никогда так не делайте
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '$username'";
$result = mysqli_query($conn, $query);
Если злоумышленник передаст admin' OR '1'='1, запрос превратится в:
SELECT * FROM users WHERE username = 'admin' OR '1'='1'
Вуаля — доступ к данным без пароля. А если передать '; DROP TABLE users; --? Прощай, таблица пользователей.
PDO и подготовленные запросы: как это работает?
PDO (PHP Data Objects) предлагает механизм prepared statements (подготовленных выражений). Идея простая: вы отделяете структуру запроса от данных. Вместо того чтобы вставлять переменные прямо в строку, вы используете плейсхолдеры:
// Безопасный код с PDO
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute([':username' => $_POST['username']]);
Но что происходит под капотом? Это ключевой момент, который многие упускают.
Режимы эмуляции vs. нативные prepared statements
PDO может работать в двух режимах:
- Режим эмуляции (по умолчанию в старых версиях PHP) — PDO сам экранирует данные и подставляет их в запрос на стороне PHP. Это всё ещё безопасно, но теоретически уязвимо, если экранирование сломается (например, при работе с нестандартными кодировками).
- Нативный режим — PDO отправляет на сервер БД два сообщения: сначала шаблон запроса, потом отдельно данные. Сервер сам обрабатывает данные как значения, а не как код. Это абсолютно безопасно.
Чтобы включить нативный режим, отключите эмуляцию:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
Теперь запрос отправляется на сервер в два этапа. Даже если злоумышленник передаст ' OR '1'='1, сервер MySQL или PostgreSQL просто воспримет это как строковое значение, а не как часть SQL-кода. Магия!
Но всё ли так идеально? Подводные камни PDO
Вернёмся к вопросу со Stack Overflow. Ответ "да, PDO prepared statements достаточны" — но с оговорками. Вот ситуации, когда защита может дать сбой.
1. Динамические идентификаторы (имена таблиц, столбцов)
Prepared statements работают только для значений (данных). Если вы подставляете имя таблицы или столбца, плейсхолдеры не помогут:
// Опасно! Имя таблицы нельзя передавать через плейсхолдер
$stmt = $pdo->prepare("SELECT * FROM :table WHERE id = :id");
$stmt->execute([':table' => $_GET['table'], ':id' => 1]); // Ошибка!
PDO просто вставит имя таблицы как строку, и запрос сломается. Если же вы используете эмуляцию, злоумышленник может передать users; DROP TABLE users; --. Решение: белый список допустимых значений.
2. LIKE-запросы и символы подстановки
Если вы используете LIKE с пользовательским вводом, prepared statements защитят от инъекций, но не от неожиданного поведения:
$stmt = $pdo->prepare("SELECT * FROM products WHERE name LIKE :pattern");
$stmt->execute([':pattern' => "%{$_GET['search']}%"]);
Всё безопасно. Но если пользователь введёт % или _, они будут интерпретироваться как подстановочные символы. Это не инъекция, но может привести к утечке данных или медленным запросам. Используйте addcslashes() для экранирования wildcard-символов.
3. Ошибки в bindParam vs. bindValue
Многие путают эти методы. bindValue передаёт значение, а bindParam — ссылку на переменную:
$value = 'safe';
$stmt->bindParam(':param', $value, PDO::PARAM_STR);
$value = 'malicious'; // Меняем значение после bindParam
$stmt->execute(); // Используется 'malicious'!
Это не уязвимость, но может привести к путанице. Всегда используйте bindValue или передавайте массив в execute(), если не уверены.
Реальный пример: как хакер может обойти PDO?
Сценарий: разработчик использует PDO, но оставляет эмуляцию включённой и работает с кодировкой GBK (китайский стандарт). В этой кодировке некоторые многобайтовые символы могут "съедать" экранирующий слеш. Пример:
// Установка кодировки (опасно!)
$pdo->exec("SET NAMES 'GBK'");
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ?");
$stmt->execute([$_GET['name']]);
Если злоумышленник передаёт %bf%27 OR 1=1 --, в GBK последовательность %bf%27 (где %27 — кавычка) может быть интерпретирована как один символ, и экранирование сломается. Решение: всегда используйте PDO::ATTR_EMULATE_PREPARES = false и устанавливайте кодировку через DSN:
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', $user, $pass);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);