13-PHP代码审计——ThinkPHP5.0.15聚合查询漏洞分析

Posted songly_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了13-PHP代码审计——ThinkPHP5.0.15聚合查询漏洞分析相关的知识,希望对你有一定的参考价值。

漏洞环境:

Thinkphp5.0.15 <= 漏洞影响版本

poc:

id),(select sleep(5)),(username

id),(updatexml(1,concat(0x7,database(),0x7e),1)

 

什么是聚合查询?

mysql数据库中有一张users表用于存储用户信息,假如我们想要查询users表中有多少个用户的记录,可以直接执行select * from users sql语句来获取用户的个数记录,但是这样做的效率非常低,因为我们只想查询用户的总数,前面的sql语句总是把所有的用户都查询出来了,假设users表中有成百上千的用户的话,就会消耗数据库大量的系统资源,并且有些数据对我们来说也是不必要的。

 

mysql数据库内置了一些聚合查询函数来实现这些操作,常用的函数如下:

sum(列名) 求和      

max(列名) 最大值     

min(列名) 最小值     

avg(列名) 平均值     

first(列名)   第一条记录   

last(列名)    最后一条记录  

count(列名)   统计记录数   注意和count(*)的区别

 

举个例子,通过聚合查询函数count来查询users表的用户记录数,如下所示:

数据库执行sql语句后,会把统计后的结果user_count返回

 

学习了聚合查询漏洞之后,开始分析聚合查询漏洞。

 

聚合查询漏洞利用程序,通过count来接收页面提交的数据:

class Index{
    public function index(){
        $count = input('get.count');
        $data = db("users")->count($count);
        printf("user_count=".$data);
    }
}

 

正常访问网址,页面返回的结果如下:

后台返回了查询到的记录数,从调试界面可以看到执行的sql语句,既然分析的是聚合查询漏洞,那么肯定要在聚合函数上“做文章”。

 

访问网址提交poc:

后台执行的sql语句如上图所示,使用了时间盲注,从调试界面的RunTime可以看到sql语句执行5秒后才会返回结果,说明存在聚合查询漏洞。

 

 

使用报错注入方式,也可以利用成功:

因为sql语句在执行过程中报错了,所以调试界面没有看到执行的sql语句,实际上后台执行的sql语句是这样的:

SELECT COUNT(id),(updatexml(1,concat(0x7,database(),0x7e),1)) AS tp_count FROM `users` LIMIT 1 

页面把sql语句爆出来的数据库名也显示出来了。

 

现在思考这样一个问题:程序将提交的数据作为参数传给count函数,那么count函数内部是如何构造sql语句的,为什么会产生sql注入?

 

分析聚合漏洞产生的原因,定位到count函数

count函数的参数field会接收提交的数据进行拼接,作为参数传给value函数。

 

分析value函数:

public function value($field, $default = null, $force = false){
    $result = false;
    if (empty($this->options['fetch_sql']) && !empty($this->options['cache'])) {
        // 判断查询缓存
        $cache = $this->options['cache'];
        if (empty($this->options['table'])) {
            $this->options['table'] = $this->getTable();
        }
        $key    = is_string($cache['key']) ? $cache['key'] : md5($field . serialize($this->options) . serialize($this->bind));
        $result = Cache::get($key);
    }//如果没有设置查询缓存,跳过
    if (false === $result) {
        if (isset($this->options['field'])) {
            unset($this->options['field']);
        }
        //构造sql语句
        $pdo = $this->field($field)->limit(1)->getPdo();
        if (is_string($pdo)) {
            // 返回SQL语句
            return $pdo;
        }
        $result = $pdo->fetchColumn();
        if ($force) {
            $result += 0;
        }

        if (isset($cache)) {
            // 缓存数据
            $this->cacheData($key, $result, $cache);
        }
    } else {
        // 清空查询条件
        $this->options = [];
    }
    return false !== $result ? $result : $default;
}

value函数的参数field的内容现在是这样的:COUNT(id),(updatexml(1,concat(0x7,database(),0x7e),1)) AS tp_count

 

value函数内执行了这行代码field()  -->  limit()  -->  getPdo(),field函数是指定查询字段,支持字段排除和指定数据表,只是将field的内容转换成一个数组,limit函数没什么好分析的

$pdo = $this->field($field)->limit(1)->getPdo();

 

分析field函数,以下是field部分关键代码

public function field($field, $except = false, $tableName = '', $prefix = '', $alias = ''){
        ......

        //是否是字符串
	//将field的内容取出来,以逗号进行分割,放入到数组中
	if (is_string($field)) {
    	$field = array_map('trim', explode(',', $field));
	}

        ......
}

field函数做了一个很关键的操作,就是将field的内容以逗号进行分析,放入到一个数组中,这个操作在后面绕过过滤函数时起到了非常重要的作用

 

field的内容如下

 

继续分析getPdo函数

    public function getPdo() {
        // 分析查询表达式
        $options = $this->parseExpress();
        // 生成查询SQL
        $sql = $this->builder->select($options);
        // 获取参数绑定
        $bind = $this->getBind();
        if ($options['fetch_sql']) {
            // 获取实际执行的SQL语句
            return $this->connection->getRealSql($sql, $bind);
        }
        // 执行查询操作
        return $this->query($sql, $bind, $options['master'], true);
    }

 

 

这里可以看到getPdo函数内部做了很多事情,parseExpress函数将field又放入了数组$options['field']中,内容如下:

 

 

比较有意思的是,select函数内部只调用了一个函数str_replace,前面我们也说过str_replace函数是构造sql语句的核心函数,并且也做了很多过滤操作

//生成sql查询语句
public function select($options = []){
//核心过滤函数
    $sql = str_replace(
        ['%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'],
        [
            $this->parseTable($options['table'], $options),
            $this->parseDistinct($options['distinct']),
            $this->parseField($options['field'], $options),
            $this->parseJoin($options['join'], $options),
            $this->parseWhere($options['where'], $options),
            $this->parseGroup($options['group']),
            $this->parseHaving($options['having']),
            $this->parseOrder($options['order'], $options),
            $this->parseLimit($options['limit']),
            $this->parseUnion($options['union']),
            $this->parseLock($options['lock']),
            $this->parseComment($options['comment']),
            $this->parseForce($options['force']),
        ], $this->selectSql);
    return $sql;
}

str_replace函数构造sql语句时用到了一个parseField函数(这个函数非常重要),继续分析parseField函数在构造sql语句时做了哪些事情。

 

 

分析parseField函数:

protected function parseField($fields, $options = []){
    if ('*' == $fields || empty($fields)) {
        //fields不为空
        $fieldsStr = '*';
        //fields是否为数组
    } elseif (is_array($fields)) {
        // 支持 'field1'=>'field2' 这样的字段别名定义
        $array = [];
        //将fields数组中的内容依次取出
        foreach ($fields as $key => $field) {
            //这里判断了key是否为数字字符串
            if (!is_numeric($key)) {
                $array[] = $this->parseKey($key, $options) . ' AS ' . $this->parseKey($field, $options);
            } else {
                //如果key是数字字符串,执行这行代码
                $array[] = $this->parseKey($field, $options);
            }
        }
        $fieldsStr = implode(',', $array);
    }
    return $fieldsStr;
}

parseField函数内部将fields数组中的内容挨个取出来放到key中并调用了parseKey函数过滤

 

 

分析parseKey函数对key做了哪些操作:

protected function parseKey($key, $options = []){
    //提取key的值
    $key = trim($key);
    //匹配key的内容是否有特殊字符
    if (strpos($key, '$.') && false === strpos($key, '(')) {
        // JSON字段支持
        list($field, $name) = explode('$.', $key);
        $key                = 'json_extract(' . $field . ', \\'$.' . $name . '\\')';
        //匹配key是否有特殊字符
    } elseif (strpos($key, '.') && !preg_match('/[,\\'\\"\\(\\)`\\s]/', $key)) {
        list($table, $key) = explode('.', $key, 2);
        if ('__TABLE__' == $table) {
            $table = $this->query->getTable();
        }
        if (isset($options['alias'][$table])) {
            $table = $options['alias'][$table];
        }
    }       //正则过滤key
    if (!preg_match('/[,\\'\\"\\*\\(\\)`.\\s]/', $key)) {
        $key = '`' . $key . '`';
    }
    if (isset($table)) {
        if (strpos($table, '.')) {
            $table = str_replace('.', '`.`', $table);
        }
        $key = '`' . $table . '`.' . $key;
    }
    return $key;
}

trim函数首先会对key的内容进行去空格,进行特殊字符过滤,但是没有进一步对一些危险的sql函数进行过滤。

 

 

当parseField函数执行完后,会将过滤完的内容放入fieldsStr中:

 

parseField函数返回fieldsStr后,str_replace构造的sql语句是这样的:

 

数据库在执行sql语句会报错,把当前数据库暴出来

 

 

你以为漏洞分析到这里已经结束了吗?其实这里还有一个坑。

 

现在我们换成这个poc再提交

id),(if(ascii(substr((select password from users where id=1),1,1))>130,0,sleep(3))),(username

 

页面返回了数据库语法的报错,说明提交的poc没有利用成功

 

分析后台如何构造sql语句,定位到parseKey函数

protected function parseKey($key, $options = []){

      //正则过滤key
    if (!preg_match('/[,\\'\\"\\*\\(\\)`.\\s]/', $key)) {
        //给key加上单引号
        $key = '`' . $key . '`';
    }

}

这里只贴出parseKey函数的部分关键代码,前面的sql执行报错的原因就是这个正则过滤引起的。但是一般情况下正则过滤不会被触发,只有当key的内容是纯数字或纯字母的时候才会被匹配,然后给key的内容加上单引号。

 

str_replace函数最终拼接成的sql语句是这样的:

sql语句中的红色部分的参数都被单引号引起来了,这个就是正则过滤的时候加上的单引号。

 

数据库在执行这条sql语句仍然会报错,说明是sql语句的语法存在问题

 

如何绕过正则?既然正则只对纯数字,那么我们可以使用表达式或者括号的方式进行绕过:

//表达式绕过
id),(if(ascii(substr((select password from users where id=1),1*1,1))>130,0*1,sleep(3))),(username
//括号绕过
id),(if(ascii(substr((select password from users where id=1),(1),1))>130,(0),sleep(3))),(username

这里就不再演示了,大家可以自己测试一下。

 

到这里,漏洞分析结束。

以上是关于13-PHP代码审计——ThinkPHP5.0.15聚合查询漏洞分析的主要内容,如果未能解决你的问题,请参考以下文章

利用Thinkphp 5缓存漏洞实现前台Getshell

e语言代码如何审计

代码审计那些代码审计的思路

当前市面上的代码审计工具哪个比较好?

代码审计思路之PHP代码审计

代码审计利器-Seay源代码审计系统