Содержание статьи
Библиотека 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. Для удобства поместим файлы в директорию /
.
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.
.
Более близким к нашей уязвимости будет такой запрос:
http://laravelrce.vh:8080/_ignition/execute-solution
Если видишь красивую картинку с сообщением об ошибке, как на скриншоте, — значит, все идет по плану. Помимо кастомизированных страниц с сообщением об ошибке, Ignition позволяет создавать так называемые solutions. Это небольшие фрагменты кода, они помогают решить проблемы, с которыми сталкиваются разработчики. Например, вернемся к нашему роуту test
. В шаблоне мы используем переменную $name
, но не передаем ее, поэтому Laravel вернет ошибку.
Обрати внимание на кнопку Make variable optional. При нажатии к серверу уходит интересный запрос.
POST /_ignition/execute-solution HTTP/1.1Host: laravelrce.vh:8080Accept: application/jsonContent-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): ?Solution84: {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.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»