9-PHP代码审计——thinkphp3.2.3数据库内核注入漏洞

实验环境的poc:

http://www.tptest.com/index.php/home/index/index?username[0]=exp&username[1]==%27admin%27

 

数据库内核注入漏洞是thinkphp框架的数据库模块产生的漏洞,我们先通过一个案例来分析数据库操作模块的流程:

 

后台接收url请求中的username拼接到后台中执行,最终将查询到的内容返回,为了分析sql执行过程,开启debug调试模式。

class IndexController extends Controller {
    public function index(){
        $username = $_GET["username"];
        /*
         *  在这行代码中我们看到整个函数的调用过程是这样的:M() --> where() --> find(),
         * 后面我们将根据函数的调用过程进行分析。
         */
        $data =M("users")->where(array("username"=>$username))->find();
        var_dump($data);
    }
}

数据库查询数据的过程如上所示,后面的分析将围绕这个过程展开。

 

M函数先是调用了where函数:

public function where($where,$parse=null){
    if(!is_null($parse) && is_string($where)) {
        if(!is_array($parse)) {
            $parse = func_get_args();
            array_shift($parse);
        }
        $parse = array_map(array($this->db,'escapeString'),$parse);
        $where =   vsprintf($where,$parse);
    }elseif(is_object($where)){
        $where  =   get_object_vars($where);
    }
    if(is_string($where) && '' != $where){
        $map    =   array();
        $map['_string']   =   $where;
        $where  =   $map;
    }        
    if(isset($this->options['where'])){
        $this->options['where'] =   array_merge($this->options['where'],$where);
    }else{
        //将where的内容给options
        $this->options['where'] =   $where;
    }
    
    return $this;
}

where函数将where中的内容(username="test")直接赋值给$options,然后调用find函数。

 

 

分析find函数:

public function find($options=array()) {
    if(is_numeric($options) || is_string($options)) {
        $where[$this->getPk()]  =   $options;
        $options                =   array();
        $options['where']       =   $where;
    }
    // 根据复合主键查找记录
    $pk  =  $this->getPk();
    if (is_array($options) && (count($options) > 0) && is_array($pk)) {
        $count = 0;
        foreach (array_keys($options) as $key) {
            if (is_int($key)) $count++; 
        } 
        if ($count == count($pk)) {
            $i = 0;
            foreach ($pk as $field) {
                $where[$field] = $options[$i];
                unset($options[$i++]);
            }
            $options['where']  =  $where;
        } else {
            return false;
        }
    }
    // 总是查找一条记录
    $options['limit']   =   1;
    //分析表达式,获取表名和表字段
    $options            =   $this->_parseOptions($options);
    // 判断查询缓存
    if(isset($options['cache'])){
        $cache  =   $options['cache'];
        $key    =   is_string($cache['key'])?$cache['key']:md5(serialize($options));
        $data   =   S($key,'',$cache);
        if(false !== $data){
            $this->data     =   $data;
            return $data;
        }
    }
//随后调用了db->select函数,这是比较关键的一行代码
    $resultSet          =   $this->db->select($options);
    if(false === $resultSet) {
        return false;
    }
    if(empty($resultSet)) {
        return null;
    }
    if(is_string($resultSet)){
        return $resultSet;
    }

    // 读取数据后的处理
    $data   =   $this->_read_data($resultSet[0]);
    $this->_after_find($data,$options);
    $this->data     =   $data;
    if(isset($cache)){
        S($key,$data,$cache);
    }
    return $this->data;
}

find函数内部获取了主键id,设置limit=1默认只查询一条记录,调用_parseOptions函数获取表名以及表字段并赋值给options。

 

将options作为参数传给db->select函数(db->select函数是一个非常关键的函数),注意这里options是一个数组,options数组中有以下内容:

 

接着调用了db->select函数

public function select($options=array()) {
    $this->model  =   $options['model'];
    $this->parseBind(!empty($options['bind'])?$options['bind']:array());
    //生成sql语句
    $sql    = $this->buildSelectSql($options);
    $result   = $this->query($sql,!empty($options['fetch_sql']) ? true : false);
    return $result;
}

db->select内部中有一个buildSelectSql函数非常重要:该函数主要是将options数组中的内容取出来构造成sql语句,然后将sql语句传入query函数执行,将查询到的数据返回。

 

分析buildSelectSql构造sql语句的过程中做了哪些事情:

public function buildSelectSql($options=array()) {
    if(isset($options['page'])) {
        // 根据页数计算limit
        list($page,$listRows)   =   $options['page'];
        $page    =  $page>0 ? $page : 1;
        $listRows=  $listRows>0 ? $listRows : (is_numeric($options['limit'])?$options['limit']:20);
        $offset  =  $listRows*($page-1);
        $options['limit'] =  $offset.','.$listRows;
    }
    //替换sql语句
    $sql  =   $this->parseSql($this->selectSql,$options);
    return $sql;
}

buildSelectSql函数只对options中的page进行了操作(但options中并没有page参数),然后调用了parseSql函数操作sql语句。

 

分析parseSql函数:

public function parseSql($sql,$options=array()){
    $sql   = str_replace(
        array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%LOCK%','%COMMENT%','%FORCE%'),
        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->parseLock(isset($options['lock'])?$options['lock']:false),
            $this->parseComment(!empty($options['comment'])?$options['comment']:''),
            $this->parseForce(!empty($options['force'])?$options['force']:'')
        ),$sql);
    return $sql;
}

parseSql函数调用了str_replace对sql语句操作,说明一下,str_replace函数是一个非常核心的函数,从传入的参数来看,str_replace函数主要是对ThinkPHP提供的连贯操作方法都进行了安全检查。

 

补充说明:

thinkphp连贯操作是thinkphp提供的一种特性,支持数据库所有的CRUD操作,例如连贯操作支持select(查询),order(排序),limit(查询记录数),把这些操作组合起来以实现各种对数据库的操作,这就叫连贯操作(更多连贯操作的详情可参考thinkphp的官方手册:http://document.thinkphp.cn/manual_3_2.html#continuous_operation)。

str_replace函数会对options数组中每个元素(即where,limit,tables)进行连贯操作和安全检查,这里我们重点关注parseWhere函数对where连贯操作的检查。

 

分析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 ';
        }
//取出$where的内容,分别放入key和val中
        foreach ($where as $key=>$val){
//是否为数字或数字字符串
            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))){
                //     E(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函数内部主要对where进行字符串做了一系列判断,然后取出$where中的内容将username放入$key,test放入$val中,然后对调用了parseKey函数和parseWhereItem函数对where子单元过滤,并将$val作为参数传入给parseWhereItem函数。

 

分析parseWhereItem函数:

protected function parseWhereItem($key,$val) {
    $whereStr = '';
    //是否为数组
    if(is_array($val)) {
        if(is_string($val[0])) {
            //取出第一个元素并转小写
$exp   =  strtolower($val[0]);
            //这里正则会匹配exp是否有特殊字符
            if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$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];
                //依次匹配是否有exp表达式
            }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]);
            }else{
                E(L('_EXPRESS_ERROR_').':'.$val[0]);
            }
        }else {
            $count = count($val);
            $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : 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{
                    $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';
                }
            }
            $whereStr = '( '.substr($whereStr,0,-4).' )';
        }
    }else {
        //对字符串类型字段采用模糊匹配
        $likeFields   =   $this->config['db_like_fields'];
        if($likeFields && preg_match('/^('.$likeFields.')$/i',$key)) {
            $whereStr .= $key.' LIKE '.$this->parseValue('%'.$val.'%');
        }else {
            $whereStr .= $key.' = '.$this->parseValue($val);
        }
    }
    return $whereStr;
}

parseWhereItem函数判断where子单元的value是否为数组,如果不是数组则调用parseValue函数:

parseValue  --->  escapeString  --->  addslashes

 

addslashes函数对value的内容进行过滤,(addslashes函数其实是php内置的一个sql指令过滤函数)。

 

parseWhere函数执行完,$whereStr返回的内容是username=test字符串,str_replace函数最终返回的sql语句如下:

SELECT * FROM `users` WHERE `username` = 'test' LIMIT 1  

数据库将查询到的数据返回给浏览器,这里我们不难发现parseWhereItem是一个核心函数,该函数对sql语句做了最后的安全检查和过滤。

 

现在相信大家对thinkphp数据库内核sql的执行流程有了大概的了解,此时再回来分析一下漏洞的利用过程,好吧,其实前面废话那么多都是铺垫,漏洞分析现在才真正的开始。

 

这是漏洞利用的poc:http://www.tptest.com/index.php/home/index/index?username[0]=exp&username[1]==%27admin%27

提交poc之后,后台也返回了同样的结果,说明漏洞利用成功。

 

我们提交的是这样一段数据:username[0]=exp&username[1]==%27admin%27,但是在后台是以数组的形式接收的,where函数取出$where中的数据赋值给$options,也就是说现在的username是一个数组:

 

 

后面的没有什么好分析的,直接定位到parseWhereItem函数,由于前面parseWhere函数已经把$where中的内容分别放入$key和$val中,此时parseWhereItem函数中的$val参数的内容依然是一个数组:

 

$val是数组满足条件后会进一步接着过滤,继续分析:

$val数组后满足条件后将数组中的第一个元素内容取出,此时$exp=exp表达式判断满足条件,代码中没有进过任何过滤,利用了exp表达式绕过了安全检查,然后将$key和$val拼接赋值给whereStr,现在whereStr的内容就是username =admin。

 

相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页