作者:RicterZ@云鼎实验室
漏洞分析
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"
)
)
)
及 $parents
:a/b/c
,最后得到的结果为 456
。
查看一下在正常上传中,传入的 $form
:
似乎 #value
是我们传入的变量,尝试注入数组:
发现成功注入:
那么通过 NestedArray::getValue
函数,可以传入 element_parents
为 account/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;
当数组中的参数不以 # 开头时,会当作 chil
dren element 进行子渲染,所以我们传入 mail[a][#lazy_builder] ,在进行子渲染的过程中,就会得到一个干净的数组,最终导致命令执行。
其他版本
本文分析的是 Drupal 8.5.0,对于 8.4.x,在注册时默认没有上传头像处,需要在登陆后上传头像处进行攻击,对于 Drupal 7,暂时未找到可控点。