近日,流行的开源内容管理框架Drupal曝出一个远程代码执行漏洞,漏洞威胁等级为高危,攻击者可以利用该漏洞执行恶意代码,导致网站完全被控制。漏洞对应的CVE编号为CVE-2018-7600。
本篇文章对Drupal 8 – CVE-2017-7600漏洞进行了详细分析。这个漏洞看起来是一个漏洞,其实我认为,它是由两个小的鸡肋问题组成的。具体是什么呢?
CVE-2018-7600 漏洞分析
这个漏洞的根本原因出在drupal对表单的渲染上:
可见,在drupal中,我们不需要直接写html表单,而是先创建一个数组,表单呈现引擎通过位于\drupal\core\lib\Drupal\Core\Form\FormBuilder.php文件中的buildForm方法构造出一个名为$form表单,然后成对应的html表单进行呈现。
通过下图buildform的定义,可以看出它是用来构造一个表单的
最终的$form是如下图这个样子:
这个漏洞,恰恰就出在了这里。
但是对于一个drupal框架的应用程序来说,后台表单数组都是开发者写好的,像这个样子
public function form(array $form, FormStateInterface $form_state) { $user = $this->currentUser(); /** @var \Drupal\user\UserInterface $account */ $account = $this->entity; $admin = $user->hasPermission('administer users'); // Pass access information to the submit handler. Running an access check // inside the submit function interferes with form processing and breaks // hook_form_alter(). $form['administer_users'] = [ '#type' => 'value', '#value' => $admin, ]; $form['#attached']['library'][] = 'core/drupal.form'; // For non-admin users, populate the form fields using data from the // browser. if (!$admin) { $form['#attributes']['data-user-info-from-browser'] = TRUE; } // Because the user status has security implications, users are blocked by // default when created programmatically and need to be actively activated // if needed. When administrators create users from the user interface, // however, we assume that they should be created as activated by default. if ($admin) { $account->activate(); } // Start with the default user account fields. $form = parent::form($form, $form_state, $account); return $form; }
攻击者是无法改变表单数组元素的key值的。
很多应用都提供了如下的一个便利的方法:
比如要注册一个用户,用户名、密码、邮箱、电话,这些东西都填好了。当点击提交的时候,网站告诉你,用户名已存在。
这时候,你会发现,密码、邮箱、电话这些元素不需要你再次填写了,页面已经将保存下来了。
drupal系统同样有这样的功能,具体如何实现的呢?下面我们做个试验:
我们先提交个正常的表单
先在buildform函数返回处下断后
填写表单并提交
页面跳转到注册成功页面,
我们在buildform函数返回处下的断点根本没有断下来。
接着我们再按着上面的表单一模一样的注册一个看看:
但这次呢,在断点处成功断下了:
在这处断点,我们把name的值改为”kingsguard_test_1”试试
这次的返回页面如下:
整个流程是:
- 用户填写表单->表单没有问题->返回注册成功页面
- 用户填写表单->表单内容有问题(例如用户名已被注册)->调用buildform方法,把用户传入的内容一同构造为表单数组->渲染表单数组为html页面返回
这就是刚刚在buildform断点处把name值由kingsguard改为kingsguard_test_1,返回的页面里username值也变成kingsguard_test_1的原因。
到这里,攻击链已经很明确了,攻击者传入的值,可以通过buildform(方法构造表单数组,并且这个表单数组接下来还会被drupal表单呈现引擎解析为html页面。
当我们在这个注册表单页面里,如果想上传一张图片
这时候发送的请求如下
当上传成功后,往往有一个缩略图显示在那,如下图菊花处:
这个缩略图,是通过drupal\core\modules\file\src\Element\ManagedFile.php文件中的uploadAjaxCallback方法来解析。
注意,还记的上文buildform方法吗?buildform生成$form数组后,将生成的$form数组传递给uploadAjaxCallback方法来解析,目的是在返回页面上显示那个缩率的菊花。
既然流程已经捋顺了,我们通过构造poc来动态调试下,发送如下图post包:
首先会进入buildform函数来构造表单数组,接下来这个表单数组($form)会进入uploadAjaxCallback方法。
看下这个uploadAjaxCallback方法:
传入uploadAjaxCallback方法中的$form变量,就是buildform方法生成的表单数组:
$form数组传入uploadAjaxCallback方法中后,可以看到有这么一行(下图红框处):
$form_parents变量竟然可以从get中传入,意味着这个变量可控,其实就是我们poc中的element_parents=account/mail/%23value。
通过poc,此处的$form_parents变量如下图
$form_parents变量和$form通过NestedArray::getValue方法后,结果值赋给$form
新的form变量如下:
接下来看这里的renderRoot方法:
此处传入的$form变量为:
继续看renderRoot方法:
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方法
继续看render方法:
public function render(&$elements, $is_root_call = FALSE) { // Since #pre_render, #post_render, #lazy_builder callbacks and theme // functions or templates may be used for generating a render array's // content, and we might be rendering the main content for the page, it is // possible that any of them throw an exception that will cause a different // page to be rendered (e.g. throwing // \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause // the 404 page to be rendered). That page might also use // Renderer::renderRoot() but if exceptions aren't caught here, it will be // impossible to call Renderer::renderRoot() again. // Hence, catch all exceptions, reset the isRenderingRoot property and // re-throw exceptions. 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方法
继续看doRender方法:
在这个方法的505行
调用call_user_func方法
此处的参数如下:
可见,这里的
$callable=”exec”
$elements[‘#children’]=”kingsguard_text”(这里我们传入的恶意代码,这里我就不演示了)
总结:
这个漏洞看起来是一个漏洞,其实我认为,它是由两个小的鸡肋的问题组成的,第一次就是在buildform处,用户传入的变量没有受到限制,导致可以传入mail[#post_render]、mail[#type]这样的变量,但是单单这个问题,还不严重,因为对于最终渲染的html页面来说,传入的数组仍然是数组,不能被当成元素来解析。但是偏偏uploadAjaxCallback方法中的$form_parents变量是直接通过get(‘element_parents’)得来的,这下两个一结合,$form_parents把之前传入的数值当成元素了,这下就造成了一个大洞。