PHPCMS v9.6.0漏洞

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

漏洞版本环境

漏洞分析

当开启session功能后时,会发现 session 文件默认存储在 ./runtime/session 下,其文件名格式为sess_PHPSESSID 。而当我们在 PHPSESSID 中输入特殊字符时,程序还是能正常生成对应文件。所以这里存在任意文件创建漏洞,再通过路径穿越,还存在getshell的可能

这个漏洞存在于用户注册处。这里有一个可控变量 $_POST['info'] 传入了 member_input 类的 get 方法中

#phpcms/modules/member/index.php
public function register() {
  ...
    if(isset($_POST['dosubmit'])) {
    ...
        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']);                                        
        }
}

跟进$member_input->get方法,48行,中$func = $this->fields[$field]['formtype'];值为editor ,所以49调用的是editor函数

#caches/caches_model/caches_data/member_input.class.php
    function get($data) {
        $this->data = $data = trim_script($data);
        $model_cache = getcache('member_model', 'commons');
        $this->db->table_name = $this->db_pre.$model_cache[$this->modelid]['tablename'];

        $info = array();
        $debar_filed = array('catid','title','style','thumb','status','islink','description');
        if(is_array($data)) {
            foreach($data as $field=>$value) {
                if($data['islink']==1 && !in_array($field,$debar_filed)) continue;
                $field = safe_replace($field);
                $name = $this->fields[$field]['name'];
                $minlength = $this->fields[$field]['minlength'];
                $maxlength = $this->fields[$field]['maxlength'];
                $pattern = $this->fields[$field]['pattern'];
                $errortips = $this->fields[$field]['errortips'];
                if(empty($errortips)) $errortips = "$name 不符合要求!";
                $length = empty($value) ? 0 : strlen($value);
                if($minlength && $length < $minlength && !$isimport) showmessage("$name 不得少于 $minlength 个字符!");
                if (!array_key_exists($field, $this->fields)) showmessage('模型中不存在'.$field.'字段');
                if($maxlength && $length > $maxlength && !$isimport) {
                    showmessage("$name 不得超过 $maxlength 个字符!");
                } else {
                    str_cut($value, $maxlength);
                }
                if($pattern && $length && !preg_match($pattern, $value) && !$isimport) showmessage($errortips);
                if($this->fields[$field]['isunique'] && $this->db->get_one(array($field=>$value),$field) && ROUTE_A != 'edit') showmessage("$name 的值不得重复!");
                $func = $this->fields[$field]['formtype'];
                if(method_exists($this, $func)) $value = $this->$func($field, $value);

                $info[$field] = $value;
            }
        }
        return $info;
    }

跟进 editor 函数,接下来函数执行 $this->attachment->download 函数进行下载

#caches/caches_model/caches_data/member_input.class.php
function editor($field, $value) {
        $setting = string2array($this->fields[$field]['setting']);
        $enablesaveimage = $setting['enablesaveimage'];
        $site_setting = string2array($this->site_config['setting']);
        $watermark_enable = intval($site_setting['watermark_enable']);
        $value = $this->attachment->download('content', $value,$watermark_enable);
        return $value;
}

继续跟进download方法

#phpcms/libs/classes/attachment.class.php
    function download($field, $value,$watermark = '0',$ext = 'gif|jpg|jpeg|bmp|png', $absurl = '', $basehref = '')
    {
        global $image_d;
        $this->att_db = pc_base::load_model('attachment_model');
        $upload_url = pc_base::load_config('system','upload_url');
        $this->field = $field;
        $dir = date('Y/md/');
        $uploadpath = $upload_url.$dir;
        $uploaddir = $this->upload_root.$dir;
        $string = new_stripslashes($value);
        if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;
        $remotefileurls = array();
        foreach($matches[3] as $matche)
        {
            if(strpos($matche, '://') === false) continue;
            dir_create($uploaddir);
            $remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
        }
        unset($matches, $string);
        $remotefileurls = array_unique($remotefileurls);
        $oldpath = $newpath = array();
        foreach($remotefileurls as $k=>$file) {
            if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
            $filename = fileext($file);
            $file_name = basename($file);
            $filename = $this->getname($filename);

            $newfile = $uploaddir.$filename;
            $upload_func = $this->upload_func;
            if($upload_func($file, $newfile)) {
                $oldpath[] = $k;
                $GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
                @chmod($newfile, 0777);
                $fileext = fileext($filename);
                if($watermark){
                    watermark($newfile, $newfile,$this->siteid);
                }
                $filepath = $dir.$filename;
                $downloadedfile = array('filename'=>$filename, 'filepath'=>$filepath, 'filesize'=>filesize($newfile), 'fileext'=>$fileext);
                $aid = $this->add($downloadedfile);
                $this->downloadedfiles[$aid] = $filepath;
            }
        }
        return str_replace($oldpath, $newpath, $value);
    }    

函数中先对$value中的引号进行了stripslashes转义,然后使用正则匹配,其中 $ext 只允许为 gif|jpg|jpeg|bmp|png ,而我们使用 http://xxxx/1.php?a.jpg 或者 http://xxxx/1.php#a.jpg 即可绕过正则

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

接着又使用 fillurl 方法对匹配到的远程图片地址进行处理,其实就是将 # 号之后的字符全部去除,例如 http://xxxx/1.php#a.jpg 会被处理成 http://xxxx/1.php

function fillurl($surl, $absurl, $basehref = '') {
        $pos = strpos($surl,'#');
        if($pos>0) $surl = substr($surl,0,$pos);
}

fillurl 方法处理后,又回到了 download 方法。$file 值为去除 # 后的url,$newfile 为本地的文件地址,其中 $this->upload_func 的值为 copy,程序直接调用 copy 函数将远程文件复制到本地,

其中本地文件名可预测,$fileext($file) 返回上边处理后的 URL 文件名后缀php,$this->getname()函数如下,最终导致 getshell 。其中 webshell 的远程地址为 http://website/uploadfile/date('Y/md/')/date('Ymdhis').rand(100, 999).'.'.$fileext

    function getname($fileext){
        return date('Ymdhis').rand(100, 999).'.'.$fileext;
    }

在写完webshell后,程序返回到phpcms/modules/member/index.phpregister()函数

可以看到当$status > 0时会执行 SQL 语句进行 INSERT 操作,也就是向v9_member_detail的content和userid两列插入数据

其中content项带着写入本地文件的地址,使其报错将webshell的文件名读出来

#phpcms/modules/member/index.php
if(pc_base::load_config('system', 'phpsso')) {
                $this->_init_phpsso();
                $status = $this->client->ps_member_register($userinfo['username'], $userinfo['password'], $userinfo['email'], $userinfo['regip'], $userinfo['encrypt']);
                if($status > 0) {
                    $userinfo['phpssouid'] = $status;
                    //传入phpsso为明文密码,加密后存入phpcms_v9
                    $password = $userinfo['password'];
                    $userinfo['password'] = password($userinfo['password'], $userinfo['encrypt']);
                    $userid = $this->db->insert($userinfo, 1);
                    if($member_setting['choosemodel']) {    //如果开启选择模型
                        $user_model_info['userid'] = $userid;
                        //插入会员模型数据
                        $this->db->set_model($userinfo['modelid']);
                        $this->db->insert($user_model_info);
                    }

$status > 0时才可以返回报错,在phpcms/modules/member/classes/client.class.php 中有多种情况是其小于0,原因在于用户名和邮箱存在和格式等,所以在 payload 中用户名和邮箱要尽量随机。

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

#phpcms/modules/member/classes/client.class.php
/**
     * 用户注册
     * @param string $username     用户名
     * @param string $password     密码
     * @param string $email        email
     * @param string $regip        注册ip
     * @param string $random    密码随机数
     * @return int {-1:用户名已经存在 ;-2:email已存在;-3:email格式错误;-4:用户名禁止注册;-5:邮箱禁止注册;int(uid):成功}
     */

报错回显如下

➜  phpcms python exp.py
<div style="font-size:12px;text-align:left; border:1px solid #9cc9e0; padding:1px 4px;color:#000000;font-family:Arial, Helvetica,sans-serif;"><span><b>MySQL Query : </b> INSERT INTO `phpcmsv9.6.0`.`v9_member_detail`(`content`,`userid`) VALUES ('&lt;img src=http://localhost:8888/phpcms9.6.0/install_package/uploadfile/2020/0217/20200217113920698.php&gt;','13') <br /><b> MySQL Error : </b>Unknown column 'content' in 'field list' <br /> <b>MySQL Errno : </b>1054 <br /><b> Message : </b> Unknown column 'content' in 'field list' <br /><a href='http://faq.phpcms.cn/?errno=1054&msg=Unknown+column+%27content%27+in+%27field+list%27' target='_blank' style='color:red'>Need Help?</a></span></div>

在无法得到路径的情况下可以使用爆破,文件名为时间加上三位随机数,复杂度较小。

poc如下

...

补丁分析

#phpcms/libs/classes/attachment.class.php phpcms v9.6.0
foreach($remotefileurls as $k=>$file) {
            if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
            $filename = fileext($file);
            $file_name = basename($file);
            $filename = $this->getname($filename);

            $newfile = $uploaddir.$filename;
            $upload_func = $this->upload_func;
            }
        }
#phpcms/libs/classes/attachment.class.php phpcms v9.6.1
        foreach($remotefileurls as $k=>$file) {
            if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
            $filename = fileext($file);
            if(!preg_match("/($ext)/is",$filename) || in_array($filename, array('php','phtml','php3','php4','jsp','dll','asp','cer','asa','shtml','shtm','aspx','asax','cgi','fcgi','pl'))){
                continue;
            }
            $file_name = basename($file);
            $filename = $this->getname($filename);

            $newfile = $uploaddir.$filename;
            $upload_func = $this->upload_func;
            if($upload_func($file, $newfile)) {
            }
        }

增加了在获取文件扩展名后,对扩展名进行检测的操作

PHPCMS v9.6.0 SQL注入

漏洞分析

这个版本的 SQL注入 主要在于程序对解密后的数据没有进行过滤,我们来看一下漏洞文件 phpcms/modules/content/down.php

在其 init 方法中,从 GET 数据中获取了 a_k 的值,该值若能解密成程序规定格式的字符串,例如这里秘钥为 pc_base::load_config(‘system’,’auth_key’)

程序将解密后的数据用 parse_str 函数处理,这里可以变量覆盖,还可以进行 URL 解码。然后将可控变量 $id 带入数据库查询,继续跟进 get_one 方法,其中调用了 sqls 方法,将未过滤的数据直接拼接进了 SQL 语句中。

#phpcms/modules/content/down.php
public function init() {
        $a_k = trim($_GET['a_k']);
        if(!isset($a_k)) showmessage(L('illegal_parameters'));
        $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));
        if(empty($a_k)) showmessage(L('illegal_parameters'));
        unset($i,$m,$f);
        parse_str($a_k);
        if(isset($i)) $i = $id = intval($i);
        if(!isset($m)) showmessage(L('illegal_parameters'));
        if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters'));
        if(empty($f)) showmessage(L('url_invalid'));
        $allow_visitor = 1;
        $MODEL = getcache('model','commons');
        $tablename = $this->db->table_name = $this->db->db_tablepre.$MODEL[$modelid]['tablename'];
        $this->db->table_name = $tablename.'_data';
        $rs = $this->db->get_one(array('id'=>$id));    
    ...
}
#phpcms/libs/classes/model.class.php
final public function get_one($where = '', $data = '*', $order = '', $group = '') {
        if (is_array($where)) $where = $this->sqls($where);
        return $this->db->get_one($data, $this->table_name, $where, $order, $group);
}
#phpcms/libs/classes/model.class.php
final public function sqls($where, $font = ' AND ') {
        if (is_array($where)) {
            $sql = '';
            foreach ($where as $key=>$val) {
                $sql .= $sql ? " $font `$key` = '$val' " : " `$key` = '$val'";
            }
            return $sql;
        } else {
            return $where;
        }
}

现在需要构造出加密数据,使得数据能够被正常解密。sys_auth 函数的代码,其代码位于phpcms/libs/functions/global.func.php,查看函数发现当没有指定加解密用的 key 时,系统默认使用 pc_base::load_config(‘system’,’auth_key’) 作为 key,而且这个加密函数的明文加密后对应多种密文。

这样我们可以直接查找形如 sys_auth(‘可控字符串’,’ENCODE’)sys_auth(‘可控字符串’) 的代码段即可。

找到在phpcms/libs/classes/param.class.php中的set_cookie函数可以将加密的数据通过cookie返回

#phpcms/libs/classes/param.class.php
public static function set_cookie($var, $value = '', $time = 0) {
        $time = $time > 0 ? $time : ($value == '' ? SYS_TIME - 3600 : 0);
        $s = $_SERVER['SERVER_PORT'] == '443' ? 1 : 0;
        $var = pc_base::load_config('system','cookie_pre').$var;
        $_COOKIE[$var] = $value;
        if (is_array($value)) {
            foreach($value as $k=>$v) {
                setcookie($var.'['.$k.']', sys_auth($v, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s);
            }
        } else {
            print_r(sys_auth($value, 'ENCODE'));
            setcookie("hh",sys_auth($value, 'ENCODE'));
            setcookie($var, sys_auth($value, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s);
        }
}

phpcms/modules/attachment/attachments.php中的swfupload_json函数调用了set_cookie函数

phpcms/modules/attachment/attachments.php
function __construct() {
        pc_base::load_app_func('global');
        $this->upload_url = pc_base::load_config('system','upload_url');
        $this->upload_path = pc_base::load_config('system','upload_path');        
        $this->imgext = array('jpg','gif','png','bmp','jpeg');
        $this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));
        $this->isadmin = $this->admin_username = $_SESSION['roleid'] ? 1 : 0;
        $this->groupid = param::get_cookie('_groupid') ? param::get_cookie('_groupid') : 8;
        //判断是否登录
        if(empty($this->userid)){
            showmessage(L('please_login','','member'));
        }
}

public function swfupload_json() {
        $arr['aid'] = intval($_GET['aid']);
        $arr['src'] = safe_replace(trim($_GET['src']));
        $arr['filename'] = urlencode(safe_replace($_GET['filename']));
        $json_str = json_encode($arr);
        $att_arr_exist = param::get_cookie('att_json');
        $att_arr_exist_tmp = explode('||', $att_arr_exist);
        if(is_array($att_arr_exist_tmp) && in_array($json_str, $att_arr_exist_tmp)) {
            return true;
        } else {
            $json_str = $att_arr_exist ? $att_arr_exist.'||'.$json_str : $json_str;
            param::set_cookie('att_json',$json_str);
            return true;            
        }
}

跟进safe_replace函数,这个函数将敏感字符替换为空,但问题是只执行一次,所以当输入是%*27*被过滤,进而可以得到%27,可以被之前的parse_str函数正常解析。

function safe_replace($string) {
    $string = str_replace('%20','',$string);
    $string = str_replace('%27','',$string);
    $string = str_replace('%2527','',$string);
    $string = str_replace('*','',$string);
    $string = str_replace('"','&quot;',$string);
    $string = str_replace("'",'',$string);
    $string = str_replace('"','',$string);
    $string = str_replace(';','',$string);
    $string = str_replace('<','&lt;',$string);
    $string = str_replace('>','&gt;',$string);
    $string = str_replace("{",'',$string);
    $string = str_replace('}','',$string);
    $string = str_replace('\\','',$string);
    return $string;
}

看构造函数可知,执行swfupload_json函数需要检测$this->userid不为空,$this->userid的值可以来自sys_auth($_POST[‘userid_flash’], ‘DECODE’),所以我们只需要传入一个合法加密值即可。

phpcms/modules/wap/index.php 里也存在set_cookie函数,其中 $_GET[‘siteid’] 可控,但是这里有 intval 过滤,所以无法构造带有sql语句的payload,但可以构造带有数字的payload。

#phpcms/modules/wap/index.php
function __construct() {        
        $this->db = pc_base::load_model('content_model');
        $this->siteid = isset($_GET['siteid']) && (intval($_GET['siteid']) > 0) ? intval(trim($_GET['siteid'])) : (param::get_cookie('siteid') ? param::get_cookie('siteid') : 1);
        param::set_cookie('siteid',$this->siteid);    
        $this->wap_site = getcache('wap_site','wap');
        $this->types = getcache('wap_type','wap');
        $this->wap = $this->wap_site[$this->siteid];
        define('WAP_SITEURL', $this->wap['domain'] ? $this->wap['domain'].'index.php?' : APP_PATH.'index.php?m=wap&siteid='.$this->siteid);
        if($this->wap['status']!=1) exit(L('wap_close_status'));
    }

所以整个攻击流程如下:

1.通过wap模块获取sys_auth加密的数据,得到合法加密值userid

2.通过userid来绕过用户登录检测,在attachment中构造加密的sql语句

3.将加密值作为a_k的值访问down.phpinit函数,并使用parse_str函数变量覆盖,最后执行解密后的sql语句。

poc如下

...

补丁分析

#phpcms/modules/content/down.php phpcms v9.6.0
public function init() {
        $a_k = trim($_GET['a_k']);
        if(!isset($a_k)) showmessage(L('illegal_parameters'));
        $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));
        if(empty($a_k)) showmessage(L('illegal_parameters'));
        unset($i,$m,$f);
        parse_str($a_k);
        if(isset($i)) $i = $id = intval($i);
        if(!isset($m)) showmessage(L('illegal_parameters'));
        if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters'));
        if(empty($f)) showmessage(L('url_invalid'));
        $allow_visitor = 1;
        $MODEL = getcache('model','commons');
}

#phpcms/modules/content/down.php phpcms v9.6.1
public function init() {
        $a_k = trim($_GET['a_k']);
        if(!isset($a_k)) showmessage(L('illegal_parameters'));
        $a_k = sys_auth($a_k, 'DECODE', md5(PC_PATH.'down').pc_base::load_config('system','auth_key'));
        if(empty($a_k)) showmessage(L('illegal_parameters'));
        unset($i,$m,$f);
        $a_k = safe_replace($a_k);
        parse_str($a_k);
        if(isset($i)) $i = $id = intval($i);
        if(!isset($m)) showmessage(L('illegal_parameters'));
        if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters'));
        if(empty($f)) showmessage(L('url_invalid'));
        $allow_visitor = 1;
        $id = intval($id);
        $modelid  = intval($modelid);
        $catid  = intval($catid);
        $MODEL = getcache('model','commons');
}

a_k进行safe_replace过滤 ,并且对idmodelidcatid变量进行 intval 类型转换。


文章作者: hh
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hh !
 上一篇
thinkphp5.1.x反序列化利用链 thinkphp5.1.x反序列化利用链
漏洞版本环境5.1.16<=ThinkPHP版本<=5.1.39 composer create-project topthink/think=5.1.16 thinkphp5.1.16 composer update appl
2020-03-27
下一篇 
redis相关漏洞 redis相关漏洞
Redis未授权访问漏洞的利用及防护环境配置目标主机:Ubuntu 16.04.6 配置redis1后端模式启动,修改redis.conf配置文件, daemonize yes 以后端模式启动 daemonize yes再执行命令redis
2020-02-04
  目录