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 分析
这个函数可以看之前的文章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
|
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
|
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
|
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
|
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语句像下面这样:
1 2 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()
函数,也存在问题。
1 2 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
函数中增加过滤,补丁如下:
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接受数组形式的参数的基础上,虽然这种写法在查询中非常少见,但是在更新、插入的时候还是有相应的需求的。总而言之,一切用户输入都是有害的,还是那句话,框架只是简化开发的一种工具,并不能把应用安全全部交给框架来处理。