Drupal 7 - CVE-2018-7600 PoC Writeup

0x00 前言

前几天我分析了 Drupal 8.5.0 的 PoC 构造方法,但是 Drupal 7 还是仍未构造出 PoC。今天看到了 Drupalgeddon2 支持了 Drupal 7 的 Exploit,稍微分析了下,发现 PoC 构建的十分精妙,用到了诸多 Drupal 本身特性,我构造不出果然还是太菜。

首先,Drupal 7 和 Drupal 8 这两个 PoC 本质上是同一原因触发的,我说的同一个原因并不是像是 #pre_render 的 callback 这样,而是都是由于 form_parent 导致 Drupal 遍历到用户控制的 #value,接着进行 render 的时候导致 RCE。Drupal 8 中的 element_parents 十分明显,且从 $_GET 中直接获取,所以很容易的能分析出来,而 Drupal 7 中的 form_parent 就藏得比较隐晦了。

那么,这个 PoC 用到了 Drupal 中的哪些特性呢?

  • Drupal 的 router 传参

  • Drupal 的 form cache

那么,先从 router 讲起。

0x01 Router

当访问 file/ajax/name/#default_value/form-xxxx 的时候,在 menu.inc 中,Drupal 是这样处理的:

function menu_get_item($path = NULL, $router_item = NULL) {
  $router_items = &drupal_static(__FUNCTION__);
  if (!isset($path)) {
    $path = $_GET['q'];
  }
    var_dump($router_items);
  if (isset($router_item)) {
    $router_items[$path] = $router_item;
  }
  if (!isset($router_items[$path])) {
    // Rebuild if we know it's needed, or if the menu masks are missing which
    // occurs rarely, likely due to a race condition of multiple rebuilds.
    if (variable_get('menu_rebuild_needed', FALSE) || !variable_get('menu_masks', array())) {
      if (_menu_check_rebuild()) {
        menu_rebuild();
      }
    }
    $original_map = arg(NULL, $path);

    $parts = array_slice($original_map, 0, MENU_MAX_PARTS);
    $ancestors = menu_get_ancestors($parts);
    $router_item = db_query_range('SELECT * FROM {menu_router} WHERE path IN (:ancestors) ORDER BY fit DESC', 0, 1, array(':ancestors' => $ancestors))->fetchAssoc();

    if ($router_item) {
      // Allow modules to alter the router item before it is translated and
      // checked for access.
      drupal_alter('menu_get_item', $router_item, $path, $original_map);

      $map = _menu_translate($router_item, $original_map);
      $router_item['original_map'] = $original_map;
      if ($map === FALSE) {
        $router_items[$path] = FALSE;
        return FALSE;
      }

看不动?没关系,我来解释下:

  • $_GET["q"] 取出 path;
  • 将 path 分割后进行组合,得到一个数组;
  • 数组进入数据库查询;

组合的结果大概是这样:

0 = file/ajax/name/#default_value/form-xxxx
1 = file/ajax/name/#default_value/%
2 = file/ajax/name/%/form-xxxxx
3 = file/ajax/name/%/%
4 = file/ajax/%/%/%
5 = file/%/name/%/form-xxxxx
....
12 = file/%/name
13 = file/ajax
14 = file/%
15 = file

这些是什么呢?实际上这些是 Drupal 的 router,在数据库的 menu_router 表里。这么一串 array 最终和数据库中的 file/ajax 相匹配。Drupal 会根据数据库中的 page_callback 进行回调,也就是回调到 file_ajax_upload 函数。回调的现场:

可以注意到回调的参数为我们 $_GET["q"] 剩下的 name/#default_value/form-xxxx

0x02 file_ajax_upload

file_ajax_upload 即漏洞触发点了,直接分析代码就好。

function file_ajax_upload() {
  $form_parents = func_get_args();
  $form_build_id = (string) array_pop($form_parents);

  if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) {
      ...
  }

  list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form();

  if (!$form) {
      ...
  }

  // Get the current element and count the number of files.
  $current_element = $form;
  foreach ($form_parents as $parent) {
    $current_element = $current_element[$parent];
  }
  $current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0;

  // Process user input. $form and $form_state are modified in the process.
  drupal_process_form($form['#form_id'], $form, $form_state);

  // Retrieve the element to be rendered.
  foreach ($form_parents as $parent) {
    $form = $form[$parent];
  }

  // Add the special Ajax class if a new file was added.
  if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
    $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
  }
  // Otherwise just add the new content class on a placeholder.
  else {
    $form['#suffix'] .= '<span class="ajax-new-content"></span>';
  }

  $form['#prefix'] .= theme('status_messages');
  $output = drupal_render($form);

这段代码的作用为:

  1. 获取参数的最后一个值作为 $form_build_id,验证这个值和 $_POST["form_build_id"] 是否相等;
  2. 通过 $form_build_idajax_get_form获取被缓存$form
  3. foreach ($form_parents as $parent) 这个循环即和 Drupal 8 中的 NestedArray::getValue 异曲同工,将 $form 中的值按照 name/#default_value 的路径取出;
  4. 最后,drupal_render($form); 进行渲染,这是漏洞的最终触发点,不做详细分析。

这是一个获取到最终 $form 的现场:

0x03 Form Cache

现在的问题是怎么得到一个被缓存$form。首先我们 POST 一个找回密码的请求包,内容如下:

通过分析代码,可以得知,若想 $form 被 cache,需要满足以下几个条件:

    if (($form_state['rebuild'] || !$form_state['executed']) && !form_get_errors()) {
      // Form building functions (e.g., _form_builder_handle_input_element())
      // may use $form_state['rebuild'] to determine if they are running in the
      // context of a rebuild, so ensure it is set.
      $form_state['rebuild'] = TRUE;
      $form = drupal_rebuild_form($form_id, $form_state, $form);
    }

drupal_rebuild_form 中:

function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) {
  $form = drupal_retrieve_form($form_id, $form_state);
  ....
  if (empty($form_state['no_cache'])) {
    form_set_cache($form['#build_id'], $form, $form_state);
  }

在诸多条件中,($form_state['rebuild'] || !$form_state['executed']) 是默认就被满足的,唯一的问题是 form_get_errors() 会出现问题。由于我们 POST 的 name 需要注入 payload,那么必然会验证失败。

如上图所示,form_get_errors返回了一个错误信息。我们跟进form_set_errors 看一看,这个函数名字像是设置错误信息的函数。

function form_set_error($name = NULL, $message = '', $limit_validation_errors = NULL) {
  $form = &drupal_static(__FUNCTION__, array());
  $sections = &drupal_static(__FUNCTION__ . ':limit_validation_errors');
  if (isset($limit_validation_errors)) {
    $sections = $limit_validation_errors;
  }

  if (isset($name) && !isset($form[$name])) {
    $record = TRUE;
    if (isset($sections)) {
      // #limit_validation_errors is an array of "sections" within which user
      // input must be valid. If the element is within one of these sections,
      // the error must be recorded. Otherwise, it can be suppressed.
      // #limit_validation_errors can be an empty array, in which case all
      // errors are suppressed. For example, a "Previous" button might want its
      // submit action to be triggered even if none of the submitted values are
      // valid.
      $record = FALSE;
      foreach ($sections as $section) {
        // Exploding by '][' reconstructs the element's #parents. If the
        // reconstructed #parents begin with the same keys as the specified
        // section, then the element's values are within the part of
        // $form_state['values'] that the clicked button requires to be valid,
        // so errors for this element must be recorded. As the exploded array
        // will all be strings, we need to cast every value of the section
        // array to string.
        if (array_slice(explode('][', $name), 0, count($section)) === array_map('strval', $section)) {
          $record = TRUE;
          break;
        }
      }
    }
    if ($record) {
      $form[$name] = $message;
      if ($message) {
        drupal_set_message($message, 'error');
      }
    }
  }

  return $form;
}

注意到这个 $record 变量。当 $sections 也就是通过 isset 函数检测时(也就是不为 null),$record 就会设置为 FALSE,也就不会进行错误的记录。通过查阅 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']))) {
  form_set_error(NULL, '', $form_state['triggering_element']['#limit_validation_errors']);
}
// If submit handlers won't run (due to the submission having been triggered
// by an element whose #executes_submit_callback property isn't TRUE), then
// it's safe to suppress all validation errors, and we do so by default,
// which is particularly useful during an Ajax submission triggered by a
// non-button. An element can override this default by setting the
// #limit_validation_errors property. For button element types,
// #limit_validation_errors defaults to FALSE (via system_element_info()),
// so that full validation is their default behavior.
elseif (isset($form_state['triggering_element']) && !isset($form_state['triggering_element']['#limit_validation_errors']) && !$form_state['submitted']) {
  form_set_error(NULL, '', array());
}
// As an extra security measure, explicitly turn off error suppression if
// one of the above conditions wasn't met. Since this is also done at the
// end of this function, doing it here is only to handle the rare edge case
// where a validate handler invokes form processing of another form.
else {
    //form_set_error(NULL, '', array()); // set _triggering_element_name
    drupal_static_reset('form_set_error:limit_validation_errors');
}

当我们普通的 POST 的时候,会进入普通的最后的 else 分支,但是如果满足:

(isset($form_state['triggering_element']) && !isset($form_state['triggering_element']['#limit_validation_errors']) && !$form_state['submitted']

这个条件时,就会调用:

form_set_error(NULL, '', array());

这样调用的话,$limit_validation_errors 就是 Array,可以通过 isset,不会记录错误。我们来看一下这三个条件:

  1. isset($form_state['triggering_element']),默认为 submit 按钮,true
  2. !isset($form_state['triggering_element']['#limit_validation_errors']) ,默认设置了这个值,false
  3. !$form_state['submitted'] ,默认为 false

看起来形式严峻。首先我在将所有 $form_state['submitted'] 设置为 TRUE 的地方设置了断点,单步调试后发现断在了这个位置:

    // 如果没设置 triggering_element,那么将 triggering_element 设置为 form 的第一个 button
    if (!$form_state['programmed'] && !isset($form_state['triggering_element']) && !empty($form_state['buttons'])) {
      $form_state['triggering_element'] = $form_state['buttons'][0];
    }

    // If the triggering element specifies "button-level" validation and submit
    // handlers to run instead of the default form-level ones, then add those to
    // the form state.
    foreach (array('validate', 'submit') as $type) {
      if (isset($form_state['triggering_element']['#' . $type])) {
        $form_state[$type . '_handlers'] = $form_state['triggering_element']['#' . $type];
      }
    }

    // If the triggering element executes submit handlers, then set the form
    // state key that's needed for those handlers to run.
    if (!empty($form_state['triggering_element']['#executes_submit_callback'])) {
      #################################################
      $form_state['submitted'] = TRUE; // <--- こ↑こ↓
      #################################################
    }

又是 triggering_element,这到底是什么东西?看代码写的,如果没设置 triggering_element,那么将 triggering_element 设置为 form 的第一个 button。我搜索了设置 $form_state['triggering_element'] 的代码:

  // Determine which element (if any) triggered the submission of the form and
  // keep track of all the clickable buttons in the form for
  // form_state_values_clean(). Enforce the same input processing restrictions
  // as above.
  if ($process_input) {
          // Detect if the element triggered the submission via Ajax.
          if (_form_element_triggered_scripted_submission($element, $form_state)) {
              $form_state['triggering_element'] = $element;
          }

    // If the form was submitted by the browser rather than via Ajax, then it
    // can only have been triggered by a button, and we need to determine which
    // button within the constraints of how browsers provide this information.
    if (isset($element['#button_type'])) {
      // All buttons in the form need to be tracked for
      // form_state_values_clean() and for the form_builder() code that handles
      // a form submission containing no button information in $_POST.
      $form_state['buttons'][] = $element;
      if (_form_button_was_clicked($element, $form_state)) {
        $form_state['triggering_element'] = $element;
      }
    }
  }

进入_form_element_triggered_scripted_submission

/**
 * Detects if an element triggered the form submission via Ajax.
 *
 * This detects button or non-button controls that trigger a form submission via
 * Ajax or some other scriptable environment. These environments can set the
 * special input key '_triggering_element_name' to identify the triggering
 * element. If the name alone doesn't identify the element uniquely, the input
 * key '_triggering_element_value' may also be set to require a match on element
 * value. An example where this is needed is if there are several buttons all
 * named 'op', and only differing in their value.
 */
function _form_element_triggered_scripted_submission($element, &$form_state) {
  if (!empty($form_state['input']['_triggering_element_name']) && $element['#name'] == $form_state['input']['_triggering_element_name']) {
    if (empty($form_state['input']['_triggering_element_value']) || $form_state['input']['_triggering_element_value'] == $element['#value']) {
      return TRUE;
    }
  }
  return FALSE;
}

这段代码的意思是,如果用户输入的 _triggering_element_value$element['#name'] 相等,那么就万事大吉了。那么,我将 POST 的 _triggering_element_name 设置成 name,在此处下一个断点,获取到的现场如下:

$form_state['triggering_element'] 果然变成了 name 元素。继续单步:

发现此处三个条件都满足,执行了:

form_set_error(NULL, '', array());

继续跟进:

进入缓存设置函数。最终查看数据库:

0x04 Inject # to Form

现在我们可以得到一个被缓存的 $form,但是,这个被缓存的 $form 并没有注入我们想要的数组,所以也就不能通过 0x02 所述的漏洞触发点进行触发。现在的问题是,如何将我们的 payload 注入到 $form 里。

单步跟入到 user_pass 函数:

function user_pass() {
  global $user;

  $form['name'] = array(
    '#type' => 'textfield',
    '#title' => t('Username or e-mail address'),
    '#size' => 60,
    '#maxlength' => max(USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH),
    '#required' => TRUE,
    '#default_value' => isset($_GET['name']) ? $_GET['name'] : '',
  );
  // Allow logged in users to request this also.
  if ($user->uid > 0) {
    $form['name']['#type'] = 'value';
    $form['name']['#value'] = $user->mail;
    $form['mail'] = array(
      '#prefix' => '<p>',
      // As of https://www.drupal.org/node/889772 the user no longer must log
      // out (if they are still logged in when using the password reset link,
      // they will be logged out automatically then), but this text is kept as
      // is to avoid breaking translations as well as to encourage the user to
      // log out manually at a time of their own choosing (when it will not
      // interrupt anything else they may have been in the middle of doing).
      '#markup' =>  t('Password reset instructions will be mailed to %email. You must log out to use the password reset link in the e-mail.', array('%email' => $user->mail)),
      '#suffix' => '</p>',
    );
  }
  $form['actions'] = array('#type' => 'actions');
  $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('E-mail new password'));

  return $form;
}

可以发现,$form['name']['#default_value'] 是直接从 $_GET['name'] 获取的,而这个注入的 $form 又是直接储存在缓存内的,那么我们将 POST 的 name 转移到 GET 中,再观察数据库中缓存的数组:

我们成功的将 payload 注入到 #default_value 里,那么,再利用 0x02 中所说的漏洞触发点触发即可。

0x05 The Exploit

最终 payload 分为两个请求。 请求 1,将 Payload 注入缓存中:

获取到 form_build_id,再进行请求 2,执行 payload:


Drupal CVE-2018-7600 分析及 PoC 构造

漏洞分析

Drupal 在 3 月 28 日爆出一个远程代码执行漏洞,CVE 编号 CVE-2018-7600,通过对比官方的补丁,可以得知是请求中存在 # 开头的参数。Drupal Render API 对于 # 有特殊处理,比如如下的数组:

$form['choice_wrapper'] = array(
  '#tree' => FALSE, 
  '#weight' => -4, 
  '#prefix' => '<div class="clearfix" id="poll-choice-wrapper">', 
  '#suffix' => '</div>',
);

比如 #prefix 代表了在 Render 时元素的前缀,#suffix 代表了后缀。

通过查阅 Drupal 的代码和文档,可以知道,对于 #pre_render#post_render#submit#validate 等变量,Drupal 通过 call_user_func 的方式进行调用。

在 Drupal 中,对于 #pre_render 的处理如下:

// file: \core\lib\Drupal\Core\Render\Renderer.php
if (isset($elements['#pre_render'])) {
    foreach ($elements['#pre_render'] as $callable) {
      if (is_string($callable) && strpos($callable, '::') === FALSE) {
        $callable = $this->controllerResolver->getControllerFromDefinition($callable);
      }
      $elements = call_user_func($callable, $elements);
    }
  }

所以如果我们能将这些变量注入到 $form 数组中,即可造成代码执行的问题。

但是由于 Drupal 代码复杂,调用链很长,所以导致了所谓“开局一个 #,剩下全靠猜”的尴尬局面,即使知道了漏洞触发点,但是找不到入口点一样尴尬。直到昨日,CheckPoint 发布了一篇分析博客,我才注意到原来 Drupal 8.5 提供了 Ajax 上传头像的点,并且明显存在一个 $form 数组的操纵。在已经知道触发点的情况下,构造剩下的 PoC 就非常容易了。

PoC 构造

CheckPoint 提供的截图显示,是在 Drupal 8.5.0 注册处,漏洞文件为:\core\modules\file\src\Element\ManagedFile.php,代码如下:

public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
  /** @var \Drupal\Core\Render\RendererInterface $renderer */
  $renderer = \Drupal::service('renderer');

  $form_parents = explode('/', $request->query->get('element_parents'));

  // Retrieve the element to be rendered.
  $form = NestedArray::getValue($form, $form_parents);

  // Add the special AJAX class if a new file was added.
  $current_file_count = $form_state->get('file_upload_delta_initial');
  if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
    $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
  }
  // Otherwise just add the new content class on a placeholder.
  else {
    $form['#suffix'] .= '<span class="ajax-new-content"></span>';
  }

  $status_messages = ['#type' => 'status_messages'];
  $form['#prefix'] .= $renderer->renderRoot($status_messages);
  $output = $renderer->renderRoot($form);

代码第五行,取出 $_GET["element_parents"] 赋值给 $form_parents,然后进入 NestedArray::getValue 进行处理:

public static function &getValue(array &$array, array $parents, &$key_exists = NULL) {
  $ref = &$array;
  foreach ($parents as $parent) {
    if (is_array($ref) && (isset($ref[$parent]) || array_key_exists($parent, $ref))) {
      $ref = &$ref[$parent];
    }
    else {
      $key_exists = FALSE;
      $null = NULL;
      return $null;
    }
  }
  $key_exists = TRUE;
  return $ref;
}

NestedArray::getValue 函数的主要功能就是将 $parents 作为 key path,然后逐层取出后返回。举个例子,对于数组:

array(
  "a" => array(
    "b" => array(
      "c" => "123",
      "d" => "456"
    )
  )
)

$parentsa/b/c,最后得到的结果为 456

查看一下在正常上传是,传入的 $form

似乎 #value 是我们传入的变量,尝试注入数组:

发现成功注入:

那么通过 NestedArray::getValue 函数,可以传入 element_parentsaccount/mail/#value,最后可以令 $form 为我们注入的数组:

在 Render API 处理 #pre_render 时候造成代码执行:

Exploit 构造

虽然实现了代码执行,但是 #pre_render 调用的参数是一个数组,所以导致我们不能任意的执行代码。不过 Render API 存在很多可以查看的地方,通过翻阅 Renderer::doRender 函数,注意到 #lazy_builder

  $supported_keys = [
    '#lazy_builder',
    '#cache',
    '#create_placeholder',
    '#weight',
    '#printed'
  ];
  $unsupported_keys = array_diff(array_keys($elements), $supported_keys);
  if (count($unsupported_keys)) {
    throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: %s.', implode(', ', $unsupported_keys)));
  }
}
...
// Build the element if it is still empty.
if (isset($elements['#lazy_builder'])) {
  $callable = $elements['#lazy_builder'][0];
  $args = $elements['#lazy_builder'][1];
  if (is_string($callable) && strpos($callable, '::') === FALSE) {
    $callable = $this->controllerResolver->getControllerFromDefinition($callable);
  }
  $new_elements = call_user_func_array($callable, $args);
  ...
}

#lazy_builder 是一个 array,其中元素 0 为函数名,参数 1 是一个数组,是参数列表。接着利用 call_user_func_array 进行调用。不过注意到上方这段代码:

$unsupported_keys = array_diff(array_keys($elements), $supported_keys);

意思为传入的 $elements 数组中不能存在除了 $supported_keys 之外的 key,常规传入的数组为:

比要求的数组多了 #suffix#prefix。不过 Render API 有 children element 的说法:

// file: \core\lib\Drupal\Core\Render\Element.php
public static function children(array &$elements, $sort = FALSE) {
  ...  
  foreach ($elements as $key => $value) {
    if ($key === '' || $key[0] !== '#') {
      if (is_array($value)) {
        if (isset($value['#weight'])) {
          $weight = $value['#weight'];
          $sortable = TRUE;
        }
        else {
          $weight = 0;

当数组中的参数不以 # 开头时,会当作 children element 进行子渲染,所以我们传入 mail[a][#lazy_builder] ,在进行子渲染的过程中,就会得到一个干净的数组,最终导致命令执行。