0x01 前言

听说thinkphp又出事了,之前看过一次tp5的源码,不过只看了查询(select)的过程,这次问题出在update和insert中,但是归根结底还是fileExp()这个函数出了问题,分析过程有部分重复,建议配合前文食用:)

0x02 准备工作

根据payload

1
name[0]=dec&name[1]=1 and (extractvalue(1,concat(1,(user()))))#&name[2]=1

dec是thinkphp实现的一个sql的表达式,和之前的not like性质类似。

dec这个表达式是在5.0.13新实现的,该漏洞在5.0.16被修复,所以影响范围也在这之间。

测试代码:

application\index\controller\Index.php

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

class Index
{
public function index()
{
$username = input('post.name/a');
db('user')->where(['id'=> 1])->update(['username'=>$username]);
}
}

0x03 分析

3.1 input()

这个函数可以看之前的文章Thinkphp5实现安全数据库操作以及部分运行流程分析的3.1部分,流程是相同的。

3.2 update()

直接定位到这个漏洞关键点,thinkphp/library/think/db/Builder.php的update函数。

具体定位过程可以看之前文章的3.2部分。

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
/**
* 生成update SQL
* @access public
* @param array $data 数据
* @param array $options 表达式
* @return string
*/
public function update($data, $options)
{
$table = $this->parseTable($options['table'], $options);
$data = $this->parseData($data, $options);
if (empty($data)) {
return '';
}
foreach ($data as $key => $val) {
$set[] = $key . '=' . $val;
}

$sql = str_replace(
['%TABLE%', '%SET%', '%JOIN%', '%WHERE%', '%ORDER%', '%LIMIT%', '%LOCK%', '%COMMENT%'],
[
$this->parseTable($options['table'], $options),
implode(',', $set),
$this->parseJoin($options['join'], $options),
$this->parseWhere($options['where'], $options),
$this->parseOrder($options['order'], $options),
$this->parseLimit($options['limit']),
$this->parseLock($options['lock']),
$this->parseComment($options['comment']),
], $this->updateSql);

return $sql;
}

问题出在更新数据处,跟进parseData函数。

3.2.1 parseData()
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 数据分析
* @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)) {
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . $val[2];
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . $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;
}

可以看到当$val是数组时,会将其第一个元素取出来作为表达式。这里有exp、des、inc三个表达式,但是exp已经被过滤了(filterExp里的第一个。。。)。第二个元素进入了parseKey函数,第三个元素没有任何操作直接拼接。跟进parseKey()看一下:

3.2.2 parseKey()
1
2
3
4
5
6
7
8
9
10
11
/**
* 字段名分析
* @access protected
* @param string $key
* @param array $options
* @return string
*/
protected function parseKey($key, $options = [])
{
return $key;
}

emmmm,不要慌。thinkphp/library/think/db/Builder.php只是一个抽象类,具体实现在这里

thinkphp/library/think/db/builder/Mysql.php

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
/**
* 字段和表名处理
* @access protected
* @param string $key
* @param array $options
* @return string
*/
protected function parseKey($key, $options = [])
{
$key = trim($key);
if (strpos($key, '$.') && false === strpos($key, '(')) {
// JSON字段支持
list($field, $name) = explode('$.', $key);
$key = 'json_extract(' . $field . ', \'$.' . $name . '\')';
} elseif (strpos($key, '.') && !preg_match('/[,\'\"\(\)`\s]/', $key)) {
list($table, $key) = explode('.', $key, 2);
if ('__TABLE__' == $table) {
$table = $this->query->getTable();
}
if (isset($options['alias'][$table])) {
$table = $options['alias'][$table];
}
}
if (!preg_match('/[,\'\"\*\(\)`.\s]/', $key)) {
$key = '`' . $key . '`';
}
if (isset($table)) {
if (strpos($table, '.')) {
$table = str_replace('.', '`.`', $table);
}
$key = '`' . $table . '`.' . $key;
}
return $key;
}

并没有什么过滤,不过在某些情况下会用反引号把字段的值包起来,可能会影响payload的构造。

经过这么一系列操作构造出来的sql语句像下面这样:

1
2
3
update `user` set username=1 #name[1] 可控
- #name[0] dec => -
1 #name[2] 可控

有两处可控,约等于为所欲为。

mark

3.3 insert()

thinkphp/library/think/db/Builder.php构造insert语句的时候同样使用了parseData()函数,也存在问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 生成insert SQL
* @access public
* @param array $data 数据
* @param array $options 表达式
* @param bool $replace 是否replace
* @return string
*/
public function insert(array $data, $options = [], $replace = false)
{
// 分析并处理数据
$data = $this->parseData($data, $options);
}

0x04 防御

thinkphp在5.0.16版本修复了这个漏洞,并没有在filterExp函数中增加过滤,补丁如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
elseif (is_array($val) && !empty($val)) {
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
if ($key == $val[1]) {
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
}
break;
case 'dec':
if ($key == $val[1]) {
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
}
break;
}
}

加了一个判断,保证$val[1]为对应的字段名,并且把$val[2]进行了强制类型转换。

最后说一句,漏洞的利用是建立在thinkphp接受数组形式的参数的基础上,虽然这种写法在查询中非常少见,但是在更新、插入的时候还是有相应的需求的。总而言之,一切用户输入都是有害的,还是那句话,框架只是简化开发的一种工具,并不能把应用安全全部交给框架来处理。