Содержание статьи
- Как PHP общается с базой данных
- PHP Data Objects
- Эмуляция подготовленных запросов в PDO
- Вариант 1. Эмуляция включена (режим по умолчанию)
- Вариант 2. Эмуляция отключена (нативные запросы)
- Режим эмуляции в драйверах
- Уязвимость 1: байт NUL, который съедает кавычку
- Убедимся, что функция PDO::quote() ни при чем
- Ломаем логику внутри PDO::prepare()
- Как именно пропадает кавычка
- Пример эксплуатации уязвимости
- Исправление
- Уязвимость 2: один битый байт роняет воркер
- Четыре шага до падения
- Пример эксплуатации
- Исправление
- Заключение
Ну а если что‑то пойдет не так, на помощь, как принято считать, придет специальная конструкция языка для обработки исключений.
Это исследование получило первое место на Pentest Award 2026 в категории «Системный взлом». Соревнование ежегодно проводится компанией Awillix.
Мы решили проверить, насколько надежно эти механизмы работают на практике. Для этого заглянули в исходный код PHP — в реализацию расширения PDO на C и код его драйверов для разных СУБД.
В итоге нашли две уязвимости, которым присвоили следующие идентификаторы CVE:
- CVE-2025-14179 — SQL-инъекция в драйвере
pdo_firebird; - CVE-2025-14180 — отказ в обслуживании из‑за разыменования нулевого указателя в PDO при использовании драйвера
pdo_pgsql.
Мы подробно расскажем об уязвимостях, которые нашли. Но сначала немного погрузимся в теорию и разберемся, что происходит внутри PDO и как ошибки в коде на C приводят к неожиданным последствиям.
Как PHP общается с базой данных
PHP не ходит в базы данных напрямую. Между нашим кодом и СУБД стоит прослойка — расширения (extensions), которые делятся на два вида:
- PDO — единый API для работы с базами данных. Для каждой СУБД нужен свой драйвер:
pdo_pgsql,pdo_mysql,pdo_firebirdи так далее; - нативные расширения вроде
mysqli,pgsql,sqlite3, заточенные под конкретную СУБД.
У каждого расширения для работы с базами данных своя кухня. Например, расширения для PostgreSQL и Firebird используют официальные клиентские библиотеки на C — libpq и fbclient. SQLite устроен проще: отдельный сервер ему не нужен, потому что движок базы данных работает прямо внутри процесса PHP. Есть и универсальный вариант — прослойка ODBC.

На схеме показано, как код приложения обращается к расширению, а оно — к клиентской библиотеке конкретной базы, встроенному движку SQLite или ODBC. Дальше запрос добирается до СУБД.
Схема может вызвать вопросы. Давай разберем основные.
ODBC (Open Database Connectivity) — универсальный API для работы с базами данных. Приложение обращается к интерфейсу ODBC, а диспетчер драйверов передает запросы нужному драйверу ODBC, который взаимодействует с конкретной СУБД. Так ODBC отделяет общий интерфейс доступа к базам от особенностей работы с каждой СУБД.
С расширениями pdo_sqlsrv и sqlsrv появляется дополнительный уровень: они используют Microsoft ODBC Driver for SQL Server, а в Unix-подобных системах нужен еще и диспетчер драйверов ODBC, например unixODBC. В Windows такой диспетчер уже встроен в систему, поэтому там цепочка короче.
В итоге цепочка выглядит примерно так: PHP → pdo_sqlsrv → ODBC → Microsoft ODBC Driver for SQL Server → SQL Server.
Есть и другой путь — pdo_dblib на базе FreeTDS, но для нашей истории он не особенно важен.
Исторически расширения PHP для MySQL могли использовать одну из двух клиентских библиотек: классическую внешнюю libmysqlclient от разработчиков MySQL или встроенный драйвер mysqlnd (MySQL Native Driver), который создали специально для экосистемы PHP. В документации PHP уже давно настоятельно рекомендуют выбирать именно mysqlnd.
Документация гласит:
It is recommended to use the mysqlnd library instead of the MySQL Client Server library (libmysqlclient). Both libraries are supported and constantly being improved.
PHP Data Objects
PDO (PHP Data Objects) — стандартный слой абстракции для доступа к базам данных в PHP. Он дает единый объектный API для работы с разными СУБД. Соединение представлено объектом класса PDO, а подготовленный запрос и его результаты — объектом класса PDOStatement. Благодаря этому базовые операции — подготовка и выполнение запросов, работа с транзакциями и выборка данных — выглядят одинаково независимо от драйвера внутри. Такой подход уменьшает привязку кода к конкретной СУБД и упрощает его поддержку.

СУБД выбирается при подключении. Префикс DSN — часть строки до двоеточия — определяет, какой драйвер PDO будет использован. Здесь мы показываем примеры с pdo_pgsql и pdo_firebird.
<?php // Подключение к PostgreSQL $dsn_pg = 'pgsql:host=localhost;port=5432;dbname=db'; $pdo_pg = new PDO($dsn_pg, 'user', 'password');// Подключение к Firebird$dsn_fb = 'firebird:dbname=192.168.1.2:/data/db.fdb;charset=utf8;';$pdo_fb = new PDO($dsn_fb, 'user', 'password');Эмуляция подготовленных запросов в PDO
У PDO есть режим эмуляции подготовленных запросов. В этом режиме PDO сам подставляет значения в строку SQL, экранируя их средствами драйвера, и отправляет на сервер готовый текстовый запрос, не используя нативные PREPARE и EXECUTE. Подготовка запроса целиком проходит на стороне клиента.
Зачем так делают? Запрос не завязан на бинарный протокол конкретной СУБД и спокойно проходит через любые прослойки. Но при этом теряются серверные плюшки вроде кеша планов и строгой типизации при биндинге. А главное — при эмуляции драйвер должен сам экранировать данные. И если он делает это неправильно, жди беды.
Разницу между режимами проще всего показать на логах MySQL: поведение pdo_mysql в них кардинально различается. Возьмем один и тот же запрос и посмотрим, что уходит на сервер в каждом режиме.
Вариант 1. Эмуляция включена (режим по умолчанию)
Параметры целиком готовятся на стороне PHP: драйвер экранирует данные, подставляет их в шаблон и отправляет на сервер готовый текст запроса.
<?php$pdo = new PDO('mysql:host=127.0.0.1;dbname=db;charset=utf8mb4', 'user', 'password');$stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?');$stmt->execute([1]); // Передаем число 1 в качестве параметраforeach ($stmt->fetchAll() as $row) { echo $row['id'] . ':' . $row['name'] . PHP_EOL;}В общем логе MySQL (General Query Log) видно, что сервер получает обычный текстовый запрос Query: число 1 на стороне PHP превратилось в экранированную строку '1'.
Id Command Argument
48 Connect user@localhost on db using TCP/IP
48 Query SELECT id, name FROM users WHERE id = '1'
48 Quit
Вариант 2. Эмуляция отключена (нативные запросы)
При нативной подготовке запросов PDO использует бинарный протокол MySQL. Сначала на сервер уходит шаблон запроса (команда COM_STMT_PREPARE), затем — параметры отдельной командой COM_STMT_EXECUTE, в виде значений, без всякого экранирования.
<?php$options = [ // Отключаем эмуляцию PDO::ATTR_EMULATE_PREPARES => false,];$pdo = new PDO('mysql:host=127.0.0.1;dbname=db;charset=utf8mb4', 'user', 'password', $options);$stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?');// Передаем число 1$stmt->execute([1]);foreach ($stmt->fetchAll() as $row) { echo $row['id'] . ':' . $row['name'] . PHP_EOL;}В логе видны обе фазы, а в Execute уходит исходное число без кавычек.
-- Сервер обрабатывает запрос в два этапа (команды Prepare и Execute).
-- Обрати внимание: в Execute передается оригинальное число 1 без кавычек.
Id Command Argument
49 Connect user@localhost on db using TCP/IP
49 Prepare SELECT id, name FROM users WHERE id = ?
49 Execute SELECT id, name FROM users WHERE id = 1
49 Close stmt
49 Quit
Режим эмуляции в драйверах
Поддержка эмуляции зависит от драйвера. Сведем различия в одну таблицу.
| Драйвер PDO | ATTR_EMULATE_PREPARES | По умолчанию | Как готовит запрос |
|---|---|---|---|
pdo_mysql | Да | Вкл. | Эмуляция, можно выключить |
pdo_pgsql | Да | Выкл. | Нативный; эмуляция только при явной настройке / особых режимах |
pdo_sqlsrv | Да | Выкл. | Нативный; эмуляция опциональна |
pdo_sqlite | Нет | — | Всегда нативный prepare |
pdo_odbc | Нет | — | Всегда нативный (через ODBC SQLPrepare) |
pdo_firebird | Нет | — | Всегда нативный prepare |
Остановимся на двух строках этой таблицы — pdo_firebird и pdo_pgsql.
- Первая —
pdo_firebird. Этот драйвер всегда готовит запросы нативно, но пропускает их через собственный препроцессор с токенизатором. Именно в нем и прячется первый баг (CVE-2025-14179), который превращает корректное экранирование в классическую SQL-инъекцию. - Вторая —
pdo_pgsql. Эмуляцию подготовки запросов в этом драйвере можно включить принудительно. Как раз в этом режиме и живет второй баг (CVE-2025-14180), способный уронить весь процесс PHP из‑за разыменования нулевого указателя.
Уязвимость 1: байт NUL, который съедает кавычку
- CVE: CVE-2025-14179
- Обнаружили: Алексей Соловьев, Никита Свешников (Positive Technologies)
Первая уязвимость живет в драйвере pdo_firebird, который нужен для работы с СУБД Firebird. И вот парадокс: функция PDO:: исправно экранирует строки, но на следующем этапе — при парсинге и пересборке SQL-запроса внутри PDO:: — контекст строки ломается. Из‑за NUL-байта (\) парсер выбрасывает закрывающую кавычку строкового литерала, и пользовательский ввод становится частью исполняемого SQL-кода.
Убедимся, что функция PDO::quote() ни при чем
За первичную санитизацию строк в драйвере pdo_firebird отвечает функция firebird_handle_quoter, которую вызывает метод PDO::. Проверим, как она обрабатывает кавычки, бэктики и NUL-байты в разных представлениях.
<?php$pdo = new PDO('firebird:dbname=127.0.0.1:/var/lib/firebird/data/mirror.fdb;charset=utf8;', 'user', 'password');$p1 = $pdo->quote("alice'\x27\u{27}`\x60\u{60}"\x22\u{22}\x00\u{00}");$p2 = $pdo->quote("offensive'\x27\u{27}`\x60\u{60}"\x22\u{22}\x00\u{00}");$sql = "SELECT * FROM users WHERE username = $p1 AND department = $p2";file_put_contents('./sql_query.dump', $sql);
Продолжение доступно только участникам
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Присоединяйся к сообществу «Xakep.ru»!
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
