Нулевой байт ломает защиту PDO: как экранированная строка превращается в SQL-инъекцию, а обычный параметр — в падение сервера
Нулевой байт ломает защиту PDO: как экранированная строка превращается в SQL-инъекцию, а обычный параметр — в падение сервера
Разработчик делает всё правильно: экранирует пользовательский ввод через PDO::quote(), вставляет результат в SQL-запрос, вызывает PDO::prepare(). Стандартный паттерн, которому доверяют годами. Но если в этом вводе есть один нулевой байт — экранирование ломается не там, где его проверяли, а на шаг позже, внутри самого драйвера Firebird. Закрывающая кавычка теряется, и следующий кусок запроса, который должен был остаться данными, превращается в исполняемый SQL.
Это CVE-2025-14179 — SQL-инъекция в драйвере pdo_firebird, которую нашли Алексей Соловьёв и Никита Свешников из Positive Technologies. NVD ставит ей CVSS 9.8, Critical: сеть, низкая сложность, авторизация не нужна, вмешательство пользователя не требуется, итог — полная компрометация данных. Сам PHP Group оценивает её чуть скромнее, по новой методологии CVSS 4.0 — 7.4, High, но это тоже серьёзная проблема, просто другая шкала. CWE-89, классическая SQL-инъекция.
Рядом всплыла вторая, отдельная история — CVE-2025-14180, отказ в обслуживании в драйвере pdo_pgsql для PostgreSQL. Тут CVSS 7.5, High: тоже сеть, тоже низкая сложность, но бьёт только по доступности — сервер падает, а не отдаёт чужие данные. Рабочего публичного эксплойта пока ни для одной из двух дыр не видно, но техническая механика в обоих случаях расписана настолько подробно, что дописать PoC — вопрос пары вечеров, не более.
ЧТО ТАКОЕ PDO
PDO — это слой, через который PHP разговаривает с базами данных. Идея простая: разработчик пишет запросы через один общий интерфейс, а под капотом конкретный драйвер — для MySQL, PostgreSQL, Firebird, SQLite — переводит эти вызовы на язык нужной СУБД. Экономия огромная: не нужно держать в голове синтаксис экранирования каждой базы отдельно, PDO берёт это на себя.
Именно поэтому баг в самом PDO — это не проблема одного приложения, а проблема тысяч приложений разом. Когда ломается защита внутри драйвера, а не в коде разработчика, весь код, который делал всё правильно и просто доверял абстракции, вдруг оказывается уязвим — без единой ошибки со своей стороны.
КАК РАБОТАЕТ БАГ В FIREBIRD
Драйвер собирает SQL-запрос токен за токеном, и в исходниках PHP эта сборка живёт в функции php_firebird_preprocess. Для строковых токенов там используется вызов strncat(sql_out, start, p - start) — обычная функция копирования строк из языка C, вместо более безопасного побайтового memcpy().
И вот тут кроется ловушка. В C строка традиционно заканчивается там, где встретился нулевой байт — это правило вшито в саму природу языка. Если в пользовательском вводе такой байт есть, strncat() обрывает копирование именно в этот момент. А закрывающая кавычка, которую честно поставила PDO::quote() при экранировании, просто не успевает попасть в буфер запроса — она стояла после нулевого байта, а копирование туда уже не дошло. Экранирование само по себе отработало правильно; ломается всё на шаг позже, когда драйвер повторно собирает запрос и клеит токен без закрывающей кавычки со следующим куском строки.
Результат — запрос теряет границу между «данными» и «кодом». Всё, что шло следом и должно было остаться просто содержимым строки, парсер Firebird читает как продолжение того же литерала или как новый SQL. Хватает этого, по словам самих разработчиков PHP, для тривиальной инъекции — если, конечно, следующий кусок запроса тоже под контролем атакующего.
КАК ЭТО ЭКСПЛУАТИРУЕТСЯ
Условие срабатывания коварно именно своей обычностью: нужен ровно тот паттерн, который разработчики годами считают безопасным — экранировать через PDO::quote(), вставить в динамическую строку запроса, отдать в PDO::prepare(). Большинство даже не задумывается о риске, потому что ввод формально прошёл через штатное экранирование. Проблема в том, что защита рвётся не там, где её проверяли, а глубже — на стороне драйвера.
Атакующему остаётся вставить нулевой байт в значение, которое попадёт в такой запрос. В самом advisory разработчики показывают это буквально на трёх строчках: экранируем через PDO::quote() строку с одним нулевым байтом, экранируем через тот же PDO::quote() вторую строку — например, or 1=1--, — и подставляем оба результата в WHERE name = ... AND name = .... После экранирования запрос выглядит безопасно. После того как драйвер пересобирает его перед отправкой в Firebird, кавычка после нулевого байта пропадает, и оба условия WHERE схлопываются в одно — в выборку попадают все строки таблицы, а не одна конкретная. В боевом сценарии вместо демонстрационного or 1=1-- подставляется полноценный UNION SELECT, который вытаскивает служебные данные Firebird, включая версию движка — то, что приложение вообще никогда не должно было отдавать.
КАК РАБОТАЕТ БАГ В POSTGRESQL
Вторая дыра живёт в драйвере pdo_pgsql и срабатывает только при одном условии — если приложение явно включило PDO::ATTR_EMULATE_PREPARES, эмуляцию подготовленных выражений средствами самого PHP вместо PostgreSQL. В этом режиме PDO не отправляет запрос и параметры отдельно, а подставляет значения в шаблон прямо на стороне PHP, экранирует их и уже готовую строку кидает серверу.
Для экранирования тут используется библиотечная функция PostgreSQL PQescapeStringConn. И если во входных данных попадается байтовая последовательность, недопустимая для текущей кодировки соединения — в примере из отчёта исследователей это \x99 — функция вместо экранированной строки возвращает NULL. Этот NULL без единой проверки летит прямиком в поле plc->quoted внутренней структуры PDO.
Дальше в дело вступает pdo_parse_params(), которая обращается к длине этой строки через макрос ZSTR_LEN(plc->quoted) — а указатель там уже NULL. Разыменование нулевого указателя, сегфолт, операционная система моментально гасит процесс PHP.
ПОЧЕМУ ЭТО ОПАСНО ДАЖЕ БЕЗ RCE
Для веб-приложения такое падение — не просто временная недоступность. PHP-воркер умирает посреди обработки запроса, а try/catch здесь бессилен: сегфолт происходит на уровне C-кода самого интерпретатора, а не PHP-исключения, и код приложения его попросту не видит.
Positive Technologies разобрали это на примере условного сервиса цифровых лицензий: деньги с баланса пользователя уже списаны, а следом падение при попытке сохранить комментарий к заказу с некорректным байтом. Итог — деньги списаны, заказа нет, склад не тронут, счёт не сформирован, а пользователь видит просто ошибку бэкенда, не понимая, что случилось с его платежом.
Риск растёт в многошаговых операциях без явных транзакций. На автокоммите часть изменений вполне успевает попасть в базу до падения процесса — а следующие шаги операции уже не выполнятся никогда. В распределённой архитектуре, где сервисы дёргают друг друга, такой же сбой способен рассинхронизировать сразу несколько систем — особенно если перед падением компонент уже отправил внешний запрос куда-то ещё.
ЧТО ОПРЕДЕЛЯЕТ ИСХОД
Для SQL-инъекции в Firebird исход всегда один и тот же — если код использует pdo_firebird с паттерном «quote плюс динамическая сборка строки», он уязвим, вариантов помягче нет.
Для падения в PostgreSQL всё завязано на одну настройку — включён ли PDO::ATTR_EMULATE_PREPARES. На нативных подготовленных выражениях самой PostgreSQL атака просто не сработает: экранирование через PQescapeStringConn в этом сценарии не задействуется вообще. Многие современные фреймворки и так используют нативные prepares по умолчанию — но эмуляцию нередко включают вручную, чтобы обойти отдельные ограничения PostgreSQL на подготовленные выражения. Именно эти приложения и в зоне риска.
ОБНОВЛЕНИЕ
PHP Group уже всё закрыл. SQL-инъекцию в Firebird пофиксили в 8.2.31, 8.3.31, 8.4.21 и 8.5.6. Падение в PostgreSQL закрыли ещё раньше — в 8.1.34, 8.2.30, 8.3.29, 8.4.16 и 8.5.1. Проверить свою версию можно так:
php -v
Если версия ниже нужной для вашей ветки — пора обновляться. Тонкость в том, что штатные репозитории Debian и Ubuntu обновляют PHP довольно неспешно, и получить свежий патч-релиз вроде 8.3.31 из коробки получается не всегда. Многие production-серверы держат PHP через сторонний репозиторий Ondřej Surý (packages.sury.org) — именно он публикует актуальные минорные версии для этих дистрибутивов. Если такой репозиторий у вас подключён, обновление внутри вашей ветки — обычный apt update и установка нужного пакета (php8.3, php8.4 и так далее). Перед этим стоит глянуть apt-cache policy php8.3 (подставьте свою версию), чтобы увидеть, какая версия вообще доступна кандидатом в репозитории.
После обновления повторный запуск php -v должен показать номер не ниже патченого. Если ваш дистрибутив или образ ещё не донёс нужный релиз — на GitHub у PHP всегда можно свериться с конкретным коммитом, который закрывает нужный CVE, и убедиться, что версия, которую предлагает менеджер пакетов, реально включает фикс, а не просто более свежий номер с другими изменениями.
Если обновиться прямо сейчас нельзя, для дыры в PostgreSQL есть рабочий временный обход — просто выключить сам режим эмуляции:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
PDO перейдёт на нативные подготовленные выражения PostgreSQL — путь кода, где уязвимая PQescapeStringConn вообще не вызывается. Но это именно обход, а не фикс: держится он ровно до тех пор, пока кто-то не включит атрибут обратно.
Для SQL-инъекции в Firebird такого простого обхода нет — защита ломается внутри драйвера уже после экранирования, тут не помогут никакие настройки. Единственное, что реально снижает риск до обновления — явно отклонять на уровне приложения ввод с нулевым байтом ещё до того, как он попадёт в PDO::quote():
if (strpos($input, "\0") !== false) {
throw new InvalidArgumentException('Обнаружен нулевой байт во входных данных');
}
Это закрывает конкретно этот вектор атаки, но не саму уязвимость в драйвере — обновление PHP всё равно нужно.
ПОЧЕМУ ЭТО ВАЖНО
Обе истории бьют по одному больному месту: безопасность PHP-приложения зависит не только от кода разработчика, но и от рантайма и драйверов под капотом привычных абстракций. Механизм экранирования, которому доверяют годами, ломается не там, где его проверяли, а на шаг глубже — там, куда разработчик физически не может дотянуться без знания внутренностей конкретного драйвера.
Для владельцев сайтов на WordPress и Joomla с самостоятельно настроенными подключениями к Firebird или PostgreSQL, да и вообще для любого кастомного PHP-кода с этими базами — риск прямой: SQL-инъекция это чужие данные, изменённые или удалённые записи, а падение PostgreSQL-драйвера — реальный простой, который бьёт по конверсии и репутации. Для хостинг-провайдеров с кучей PHP-сайтов на общей инфраструктуре это ещё и повод устроить плановый аудит версий PHP по всему парку — клиенты сами такие патчи почти никогда не отслеживают.
ВЫВОДЫ
Есть PHP-приложения с драйвером Firebird через PDO — обновляйтесь до 8.2.31, 8.3.31, 8.4.21 или 8.5.6 прямо сейчас, это критичная инъекция без разумных обходов.
Работаете с PostgreSQL через PDO с PDO::ATTR_EMULATE_PREPARES — либо обновление до 8.1.34, 8.2.30, 8.3.29, 8.4.16 или 8.5.1, либо, если сейчас никак, явно выключите этот атрибут как временную меру.
Держите хостинг с чужими PHP-сайтами — проведите инвентаризацию версий по всему парку и не надейтесь, что клиенты обновятся сами: обе дыры бьют удалённо и без аутентификации, было бы что эксплуатировать.
И в любом случае не считайте экранирование PDO абсолютной защитой. Как показывают оба случая, граница безопасности может проходить не там, где вы её ждёте — не в вашем коде, а на уровне драйвера, о внутренностях которого большинство разработчиков даже не подозревает.
