代码审计之QCMS 3.0

1,069 阅读5分钟
原文链接: foreversong.cn

前言:很久没有审计php源码了,,这次到源码之家下了一套最新的源码并花了一点时间看了一下可能存在的问题,这一看还真看出问题出来了~

0x01 cms简介

cms下载地址:http://down.admin5.com/php/1421.html

QCMS 网站管理系统是通过MVC架构开发的一套PHP轻量级系统,它免费,开源,小巧,易用,功能强大. 可以自定义模块, 容易二次开发, 可以称得上是万能CMS系统, 可用于企业站,文章站,图片站,下载站,你只要能想得到,就能做的到.性能全面超越1.8版200%,超越以往最强的QCMS任何系统!

安装这块就不说了,这里展示下界面

0x02 cms审计实操

首先看一下源码的大小以及整个架构,这块还是为了之后找源代码出处服务的,如果这块不看,后面找代码可能就要费点时间,,大不了全局搜索一个个看~

第一个想到的就是重装漏洞是否存在,实际情况是install的安装界面能够打开!

但是如果看过代码之后就知道,这里虽然能打开,但是会有lock文件的判断,从而exit

一开始思索的是就算有lock文件判断,那会不会只是个摆设,并不会跳出程序,下面附上安装代码

	$configPath = '../Lib/Config/Config.php';
	$configPathTemp = '../Lib/Config/Config_bak.php';
	try{
	    new PDO('mysql:dbname='.$_POST['db_name'].';host='.$_POST['host'].'', $_POST['username'], $_POST['password'], 
	    		array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
	}catch (PDOException $e){
	    echo '数据库连接失败,请确认数据库帐号密码正确。<br>
		<a href="index.php">返回继续安装</a><br>';exit;
	}
 
	if(file_exists($configPath)){
		echo '数据库配置文件存在,请删除Lib/Config/Config.php。<br>
		<a href="index.php">返回继续安装</a><br>';exit;
	}

这里可以看到会有一个file_exists的判断,循环里也会exit,因此这里虽然能够显示页面重装,但是此处并不存在漏洞!

下面进入到页面中去看看,有哪些功能?

由于只是一个单纯的新闻推广类网站,因此这里并不存在太多的用户交互,任何一个网站可能第一步就是在参数后面加上单引号,这里亦是如此!

那么到代码中去看看关于sql这块是怎么写的

	public function detail_Action($id = 0){
		$temp['rs'] = $this->_newObj->selectOne(array('id' => $id));
		$this->_newObj->update(array('count' => ($temp['rs']['count']+1)), array('id' => $id));
		$temp['cateRs'] = $this->getCateInfo($temp['rs']['cid']);
		$temp['cid'] = $temp['rs']['cid'];
		$this->load_view('template/'.$this->web['tempname'].'/'.$temp['cateRs']['detail_temp'], $temp);
	}

这里的id参数即为可能存在注入的地方,跟进selectOne函数

	public function selectOne($cond_arr='', $field='*', $tb_name = 0,  $index = 0, $limit = '', $sort = '', $fetch = 1){
		return $this->exec_select($cond_arr, $field, $tb_name,  $index, $limit, $sort, $fetch);
	}

继续跟进这个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);
		}
	}

这里文件名叫Db_pdo.php,大意就是用了预编译来实现sql语句,到这里猜测基本上是不存在注入的,并且这里的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);
	}

那么到这里,由于没有其他功能,前台注入可以判定是不存在的!

但是这里有一处客户留言,,我们来看看留言函数有没有对非法字符进行实体化,或者使用了getip这样的函数

(当然先测试下是否存在问题。。)

一下就出问题了。。还是来看看代码怎么写的!

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;
			}
		}
		$temp['rs'] = array('name' => '客户留言');
		$count = 0;
		$this->pageNum = 6;
		$temp['module_name'] = 'guest';
		$offset = ($page <= 0) ? 0 : ($page - 1) * $this->pageNum;
		$temp['guestRs'] = $this->_guestObj->selectAll(array($offset, $this->pageNum), $count, array());
		$temp['page'] = $this->page_bar($count[0]['count'], $this->pageNum, url(array('guest', 'index', '{page}' )), 9, $page);
		$this->load_view('template/'.$this->web['tempname'].'/guest', $temp);
	}

这里没有看到一丁点的过滤函数,直接插入了数据库(当然这里不存在注入),取出来时也没有任何实体化的操作,因此这里可以判定存在存储型xss漏洞,我们可以利用这个来打管理员的cookie,也是一个不错的选择!

前台功能基本就这两处,下面来看后台

后台的话,因为这里已经挖到了一个存储型xss漏洞,那么只能配合xss打cookie,其他也没有任何思路,因此后台其实我们所能接触的到就是一个后台登录界面

没有用户注册、忘记密码等逻辑漏洞常出现的功能,到这里还有两条路要走,,一个就是后台登录的注入,还有就是后台管理员的cookie伪造

第一个还是看有无注入的可能!

	public function adminLogin($username, $password){
		$adminObj = $this->load_model('QCMS_Admin');
		$rs = $adminObj->selectOne(array('admin' => $username, 'pwd' => md5(md5($password).SITE_KEY)));
		if(empty($rs)) return false;
		$this->cookieObj->set(array('admin_id' => $rs['id'], 'admin_level' => $rs['level'], 'admin_name' => $rs['admin'], 'admin_secret' => md5($rs['id'].$rs['admin'].date('Y-m').SITE_KEY)));
 
		return true;
	}

这里跟进函数,跟前台的sql操作是一样的,会进入pdo文件,然后会有一次转义操作,因此注入不存在

但是这里埋下伏笔的是,对管理员的认证是通过cookie来完成的,而不是通过phpsession

因此这里的重头戏可能就是在这里cookie伪造上

登录进后台,对cookie做了一些测试,发现的确跟phpsessid无关,跟前面的admin_id、admin_name、admin_secret函数有关,但是什么关系现在还不得而知,得看了具体的代码才知道

这里首先根据刚才的函数来看了下,就在adminLogin上方发现了这个isLogin函数

	public function isLogin(){
		if(empty($this->userArr['id'])){
			exec_script('window.location.href="'.url(array('index', 'login')).'"');exit;
		}
		if($this->userArr['secret'] != md5(SITE_KEY.$this->userArr['username'].$this->userArr['email'].date('Ym'))){
			exec_script('window.location.href="'.url(array('index', 'login')).'"');exit;
		}
	}

但是奇怪的是上面函数对secret是md5($rs['id'].$rs['admin'].date('Y-m').SITE_KEY)这样生成的,但是在isLogin函数中secret要求是这样md5(SITE_KEY.$this->userArr['username'].$this->userArr['email'].date('Ym')

应该说还是有明显的不同的,其实在这里思考了很久,,后来在自行测试时候才发现,,这个函数就没有用到。。

对管理员判断的函数而是写在construct函数

	function __construct(){
		parent::__construct();
		$this->id 			= 	$this->cookieObj->get('admin_id');
		$this->name 	= 	$this->cookieObj->get('admin_name');
		$this->level 		= 	$this->cookieObj->get('admin_level');
		$this->secret		= 	$this->cookieObj->get('admin_secret');
		$this->uploadObj = $this->load_class('upload');
		$this->key = base64_encode($this->id.'|'.$this->secret.'|'.WEB_DOMAIN.'|'.SITE_KEY.'|'.$this->name);
		if(empty($this->id) || ($this->secret != md5($this->id.$this->name.date('Y-m').SITE_KEY))){
			exec_script('window.location.href="'.url(array('admin')).'"');exit;
		}
	}

对管理员的认证核心函数如下

if(empty($this->id) || ($this->secret != md5($this->id.$this->name.date('Y-m').SITE_KEY)))
{ exec_script('window.location.href="'.url(array('admin')).'"');exit; }

而这些id参数都是通过cookie的get方法,也就是说我们都是可控的

那么也就是说我们构造一定的cookie,然后满足管理员的条件,通过自行构造就能绕过密码登录

而构造的条件就依据上面的判断条件,首先admin_id不能为空,其次就是secret必须等于admin_id+admin_name+data()+SITE_KEY的哈希值

由于没有注册功能,因此这里的参数都是固定的,id为1,name为admin,data('Y-m')这个通过搜索就知道了,其实就是日期函数,这里由于日期其实也是固定的,比如现在是2018年2月,那么这里参数实际上就是2018-2,最后的SITE_KEY也是在配置文件就写好的‘QCMS’

那么最后作一个合并(1admin2018-02QCMS),然后进行一次md5加密

那么实际上这里的secret_key也变成可控的了(4afcc4acfa626d5a7291700b1f044435)

最后我们伪造cookie参数,直接访问后台即可

既然都到了后台,,那就找找getshell方法吧

	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);
	}

这段代码问题很大,去后缀,然后进行mime type判断,因此这里实际上就存在mime上传绕过getshell这样的问题

可以看到shell文件已上传成功(没什么技术含量。。)

0x03 总结

这套cms功能不是很多,但是这里也找到了存储型xss漏洞、后台的cookie伪造、后台getshell等漏洞,后台应该还有其他漏洞,但是相比getshell可能就没那么重要了

上述如有不当之处,敬请指出~