Наши продукты

Все продукты →
  • Подписка на «Хакер»

    Все статьи, архив, PDF выпуски и бонусы при покупке товаров

    6000₽ в год

    Подписаться
  • Предзаказ четвертого печатного выпуска «Хакера»

    лучшее август..ноябрь 2026

    2000₽

    Заказать
  • Третий печатный выпуск «Хакера»

    лучшие материалы мая..июля 2026

    2000₽

    Заказать
  • Печатный спецвыпуск №4.

    Лучшие материалы 2021-2022 годов

    2000₽

    Заказать
Каж­дый раз­работ­чик на PHP, и не толь­ко, зна­ет ман­тру: «Экра­нируй вход­ные дан­ные, и SQL-инъ­екции тебе не страш­ны». Про­пус­тить параметр через штат­ную фун­кцию экра­ниро­вания, зак­лючить резуль­тат в кавыч­ки, соб­рать SQL-зап­рос и отпра­вить его в базу дан­ных — клас­сичес­кий при­ем, которо­му раз­работ­чики доверя­ют уже мно­го лет.

Ну а если что‑то пой­дет не так, на помощь, как при­нято счи­тать, при­дет спе­циаль­ная конс­трук­ция язы­ка для обра­бот­ки исклю­чений.

Это иссле­дова­ние получи­ло пер­вое мес­то на 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.

Взаимодействие PHP-расширений с различными СУБД
Вза­имо­дей­ствие PHP-рас­ширений с раз­личны­ми СУБД

На схе­ме показа­но, как код при­ложе­ния обра­щает­ся к рас­ширению, а оно — к кли­ент­ской биб­лиоте­ке кон­крет­ной базы, встро­енно­му движ­ку 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. Бла­года­ря это­му базовые опе­рации — под­готов­ка и выпол­нение зап­росов, работа с тран­закци­ями и выбор­ка дан­ных — выг­лядят оди­нако­во незави­симо от драй­вера внут­ри. Такой под­ход умень­шает при­вяз­ку кода к кон­крет­ной СУБД и упро­щает его под­дер­жку.

Абстрактный интерфейс PDO и драйверы подключения к СУБД
Абс­трак­тный интерфейс PDO и драй­веры под­клю­чения к СУБД

СУБД выбира­ется при под­клю­чении. Пре­фикс 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
 

Режим эмуляции в драйверах

Под­дер­жка эму­ляции зависит от драй­вера. Све­дем раз­личия в одну таб­лицу.

Драй­вер PDOATTR_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::quote() исправ­но экра­ниру­ет стро­ки, но на сле­дующем эта­пе — при пар­синге и перес­борке SQL-зап­роса внут­ри PDO::prepare() — кон­текст стро­ки лома­ется. Из‑за NUL-бай­та (\0) пар­сер выб­расыва­ет зак­рыва­ющую кавыч­ку стро­ково­го литера­ла, и поль­зователь­ский ввод ста­новит­ся частью исполня­емо­го SQL-кода.

 

Убедимся, что функция PDO::quote() ни при чем

За пер­вичную санити­зацию строк в драй­вере pdo_firebird отве­чает фун­кция firebird_handle_quoter, которую вызыва­ет метод PDO::quote(). Про­верим, как она обра­баты­вает кавыч­ки, бэк­тики и 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, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии