ThinkPHP5 SQL注入漏洞分析


参考的红日安全团队的文章:
https://github.com/Passer6y/ThinkPHP-Vuln/blob/master/ThinkPHP5/ThinkPHP5%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B9%8BSQL%E6%B3%A8%E5%85%A51.md

环境搭建

第一次用composer安装源码,记录一下配置过程:
用brew安装composer:brew install composer
更新一下composer:composer self-update
安装国内镜像源:composer config -g repo.packagist composer https://packagist.laravel-china.org

安装漏洞环境,thinkphp 5.0.15:
composer create-project --prefer-dist topthink/think=5.0.15 tp5.0.15
将 composer.json 文件的 require 字段设置成如下:

1
2
3
4
"require": {
"php": ">=5.4.0",
"topthink/framework": "5.0.15"
}

将数据库文件,报错信息开启后,在控制器中index.php写:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}

paylaod:

1
https://tp5.0.15/?username[0]=inc&username[1]=updatexml(1,concat(0x7,user(),0x7e),1)&username[2]=1

image_1d8d5drlc17qg1na9vcv1tt013tt9.png-97.9kB

分析

从demo代码入手:

1
2
3
$username = request()->get('username/a');   // 强制类型转换成数组
db('users')->insert(['username' => $username]);
return 'Update success';

对其动态调试过程中,request()->get(),在thinkphp/library/think/Request.php 1093行对其数组的每个参数过滤:

1
2
3
4
5
6
7
8
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 其他安全过滤
}

显然这个过滤规则是形同虚设的。

再来跟入: db('users')->insert(['username' => $username]);
从上面payload可以发现数组的第一个值为inc, 是tp5新增的链式查询操作:https://www.kancloud.cn/manual/thinkphp5/135178,Auto-increment即自动创建主键字段值。

thinkphp/library/think/db/Builder.php 726行insert 数据处理后进行拼接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function insert(array $data, $options = [], $replace = false)
{
// 分析并处理数据
$data = $this->parseData($data, $options);
if (empty($data)) {
return 0;
}
$fields = array_keys($data);
$values = array_values($data);

$sql = str_replace(
['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'],
[
$replace ? 'REPLACE' : 'INSERT',
$this->parseTable($options['table'], $options),
implode(' , ', $fields),
implode(' , ', $values),
$this->parseComment($options['comment']),
], $this->insertSql);

return $sql;
}

thinkphp/library/think/db/Builder.php 118行选择了 inc模式 这里只是对数据进行清洗并没有对其进行过滤

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

数据清洗完后,value值被拼接进去:
image_1d8f724ato7qe5bdh8h671j8p.png-307.5kB

接着在对其一番预处理参数绑定后再进行查库,thinkphp/library/think/db/Connection.php 456行:
image_1d8f7dfs829a11t8f5l1e8na8216.png-548.3kB

同样的,从thinkphp/library/think/db/Builder.php可以知道,对Inc模式和对dec处理模式一样,所以同样也存在这个问题:
image_1d8f9s34g1qjn1ki7irg12j71vbg3a.png-290.4kB

修复过程,将composer.json版本修改成5.0.17,执行composer update:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
- $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
+ $result[$item] = $item . '+' . floatval($val[2]);
break;
case 'dec':
- $result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
+ $result[$item] = $item . '-' . floatval($val[2]);
break;
}

可以看到改为拼接数组的键了:
image_1d8faqgj6vr0nd8k9q8ouo9s44.png-409.2kB

看到这里,其实有几个疑问的:

  1. 为什么使用框架提供的参数方法过滤这么简单
  2. 将值换成键拼接还能注入吗?

对于第一个问题,因为自己对thinkphp开发流程不熟悉原因,查阅文档之后发现:
image_1d8f8g6lv13d5105o1hg7qmptdp20.png-81.1kB
显然,就像上文分析的那样,默认情况下过滤就只有那些简单的关键字。

对于第二个问题,之前以为是客户端可控的传入变量的键,后来调试的时候发现是:
image_1d8fbcjdf15o7fjp5sbc7a1o64u.png-29.5kB

最后

总结一下利用条件:

  1. 过滤规则为默认,即无过滤
  2. 获取参数为array类型, 传入的三个参数,第一个为查询模式inc/dec,第二个未过滤为payload,第三个会进行floatval()类型转换,功能为递增的step
  3. 最后就是存在一个可控的insert操作了
  4. 开启报错才能报错注入

补更

后来继续跟了一下tp5其他的注入,感觉都很鸡肋,甚至觉得不是洞..

利用思路都大同小异,传入数据默认不过滤,底层解析数据的也不过滤,最后进行一次字符串拼接。

参考:

  1. Laravel China 社区维护的国内全量镜像
  2. ThinkPHP-Vuln