Введение: Мифы и реальность PDO
Если вы пишете на PHP и хоть раз гуглили «как защититься от SQL-инъекций», то наверняка слышали мантру: «Используйте PDO и prepared statements — и будет вам счастье». Это золотой стандарт, основа любого учебника по PHP-безопасности. Но так ли это на самом деле? Достаточно ли просто написать $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?') и забыть о проблеме?
Вопрос «Are PDO prepared statements sufficient to prevent SQL injection?» набрал тысячи просмотров на Stack Overflow не просто так. Ответ, как выясняется, не такой однозначный. В этой статье мы залезем под капот PDO, разберем, как на самом деле работают подготовленные запросы, и выясним, в каких случаях даже PDO может вас подвести.
Это не скучная теория. Мы рассмотрим реальные примеры, эмуляцию запросов, нюансы с кодировками и дадим конкретные action items, чтобы ваш код был по-настоящему защищен.
Как работают Prepared Statements: Магия или математика?
Для начала давайте вспомним, что такое SQL-инъекция. Это когда злоумышленник передает в запрос данные, которые интерпретируются как часть SQL-кода. Классика: ' OR 1=1 --.
Prepared statements (подготовленные запросы) решают эту проблему кардинально. Они разделяют логику запроса и данные. Выглядит это так:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND status = :status');
$stmt->execute([':email' => $_GET['email'], ':status' => 'active']);
?>
Под капотом происходит следующее:
- Подготовка (Prepare): Драйвер отправляет серверу БД «скелет» запроса:
SELECT * FROM users WHERE email = ? AND status = ?. Сервер парсит этот скелет, оптимизирует его и запоминает план выполнения. Место для данных (плейсхолдеры) пока пусто. - Выполнение (Execute): Теперь вы отправляете только фактические значения. Сервер вставляет их в уже готовый план, но делает это особым образом — он знает, что это данные, а не код. Поэтому любые попытки «закрыть кавычку» или добавить SQL-команду просто превращаются в часть строкового литерала.
Звучит идеально. Но есть нюанс.
Подводный камень: Эмуляция Prepared Statements
Главный сюрприз, который выявил тот самый вопрос на Stack Overflow: PDO по умолчанию не всегда использует «настоящие» prepared statements.
Дело в том, что не все базы данных поддерживают эту фичу на уровне протокола. А некоторые драйверы (например, для SQLite или старых версий MySQL) работают через эмуляцию. Что это значит?
Когда эмуляция включена, PDO сам подставляет значения в SQL-запрос на стороне клиента (в PHP), а затем отправляет уже готовый запрос серверу. При этом он экранирует спецсимволы с помощью PDO::quote().
И вот здесь начинаются проблемы. Эмуляция — это, по сути, та же подстановка строк, только выполненная библиотекой. И если вы неправильно задали кодировку соединения, этот механизм может быть взломан.
Пример: Атака через разные кодировки
Классическая атака с использованием неверной кодировки выглядит так. Предположим, вы установили соединение с MySQL в кодировке GBK (китайская кодировка), а сам запрос эмулируется. Символ \ (обратный слеш) в GBK может быть частью многобайтового символа.
Злоумышленник передает строку: %BF%27. В кодировке GBK последовательность %BF%5C образует один символ, а %27 — это просто кавычка. Эмулятор видит кавычку и экранирует ее, превращая %27 в %5C%27 (то есть \'). Но из-за того, что %BF «съедает» добавленный обратный слеш, получается %BF%5C%27, где %BF%5C — это один символ, а %27 — это неэкранированная кавычка. Инъекция удалась!
Это не баг PDO, это баг конфигурации. Но он очень распространен и опасен.
Как проверить, что использует ваш PDO?
Давайте напишем простой скрипт, который покажет, что происходит под капотом вашего PDO-соединения:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass');
// Проверяем, включена ли эмуляция
$emulate = $pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES);
echo 'Эмуляция включена: ' . ($emulate ? 'Да' : 'Нет') . "\n";
// Смотрим версию драйвера
echo 'Версия драйвера: ' . $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) . ' ' .
$pdo->getAttribute(PDO::ATTR_SERVER_VERSION) . "\n";
// Отключаем эмуляцию (если драйвер поддерживает реальные prepared statements)
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
echo 'Эмуляция отключена. Теперь используем настоящие prepared statements.' . "\n";
?>
Если вы используете MySQL 5.1+ и драйвер mysqlnd (родной драйвер PHP, который идет в комплекте с PHP 5.3+), то вы можете и должны отключить эмуляцию. Это заставит PDO отправлять на сервер настоящие подготовленные запросы, и вся магия экранирования будет происходить на стороне БД, а не в PHP.
Правильная настройка: Чек-лист безопасности
Итак, чтобы ответить на вопрос «Достаточно ли PDO?» — да, достаточно, но только при правильной настройке. Вот что нужно сделать обязательно:
- Всегда указывайте кодировку в DSN. Никогда не полагайтесь на настройки по умолчанию. Пишите
charset=utf8mb4(илиutf8, если вам не нужны эмодзи). Это самый важный пункт. - Отключайте эмуляцию.
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);. Это заставит драйвер использовать реаль