Thinkphp5.0.x~5.2.x版本反序列化链挖掘学习
基础知识
namespace命名空间和子命名空间
首先我们需要知道为什么需要命名空间呢?
在大型PHP项目中,可能存在大量的类,函数和常量,而这些可能来自不同的库和团队,为了避免冲突,php就引进了命名空间。
1 | <?php |
以上例子中 Animals就是一个命令空间 而cat dog bird 都是其子命名空间
在创建实例的时候这里也有讲究
一种是直接在相关类之后进行创建实例 比如:
1 | namespace Animals\cat; |
一种是可以在任意地方创建 但是要加上命名空间 即:\命名空间\子命令空间\class类名

还有一个知识点是利用use 在这里可以理解为是require inlcude这样的函数
即可以在当前命名空间使用引入其他命名空间
比如根据代码顺序 我们创建实例的代码都属于是bird命名空间里的
也就是bird无需指定命名空间 直接申明即可

如果使用use 则直接创建实例时无需在之前加上命名空间了

类的继承
通过关键字extends来实现
通过一个demo简单理解
1 | <?php |

可以看到子类会继承父类中的属性和方法,当然只限于(public和protected类型而private是无法继承也无法访问的)子类同名函数会覆盖父类,子类也可以通过parent::method访问父类被覆盖的方法。
trait修饰符
trait修饰符使得被修饰的类可以复用,增加了代码的复用性
即我们可以在一个类中包含一个被trait修饰的类 并且使用其方法和属性
1 | <?php |

还有一个b神例子也可以来理解一下 方便之后去调试thinkphp链子
1 | <?php |

可以看到我们用trait修饰了Boogipop类并且在Z1d10t中use复用了它
然后我们创建一个Z1d10t的类 并且直接输出他
那么我们知道当直接输出一个对象时候就会调用__toString魔术方法
但是Z1d10t中并没有__toStiring魔术方法而Boogipop类中有却被调用了 这就很神奇了
5.1.x版本分析
环境搭建
- php7.3.4+Xdebug+thinkphp5.1.37+phpstudy+phpstorm
那么当然是看官方文档https://www.thinkphp.cn/doc
这里建议在安装时老老实实利用composer 用git的时候报了好多错
把下载的源码包放到phpstudy根目录下
然后就是phpstudy联动phpstorm 推荐一篇文章:phpstorm+phpstudy调试thinkphp_如何在phpstorm中调试php程序-CSDN博客
这里遇到的坑就是我composer下载指定thinkphp版本为5.1.37时 他自动下载为thinkphp5.1.42
折磨了我一下午 最终问gpt得到了解决方法
在我们下好thinkphp5.1.42目录下 有个composer.json 将原来的5.1.*改为指定的5.1.37

然后 composer update 他就会自动重新帮我们拉取依赖

最终解决了 可以愉快的调试了
挖掘
首先我们需要设置一个触发点 将控制器中的index控制器修改为
1 | <?php |
然后在反序列化入口函数处打一个断点

先测试我们的poc 可以发现是可以正常触发的

接下来一步步调试
首先是进入了Windows.php的Windows类的析构函数
我们观察poc 也可以发现最外层套的是think\process\pipes\Windows对象 因此首先会进入该对象
这里windows类还继承了Pipes这个抽象类

继续跟进 进入了removeFIles这个函数中

该方法会遍历files数组 然后删除
这里调用了一个$this->files并且$files是可控的因此这里还存在一个任意文件删除的漏洞
poc:
1 | <?php |
file_exists函数对$filename进行处理 并且此时$filename会被当作字符串string进行处理

并且根据我们的poc可知 我们传入的是 Pivot对象


那当一个对象当作字符串被处理时,会触发__toString魔术方法
继续跟进 发现难道不应该触发Pivot函数的__toString魔术方法吗 为什么会跳到Conversion对象

这里就用到了之前说的trait复用的知识点了
我们可以看到 Convertion被trait修饰了

并且Pivot对象基类是Model 它内部没有toString魔术方法
但是构造函数中调用的是父类的构造方法

我们继续跟进Model类 可以发现它use了model\concern\Conversion

因此Model复用了Conversion类 而它又是Pivot类的父类
当Pivot对象被当作字符串输出的时候 就会调用Conversion类的toString方法
这也就是为什么我跟进链子到了 Converison类的tostring方法了
思路清晰 继续
接着调用了toJson函数

接着调用了toArray函数 然后转为了json字符串 继续跟进toArray
先遍历this->append属性 取出键值对 对应我们poc中的

key为Z1d10t

接着进入getRelation函数中

因为我们没有给$this->relation赋值 因此return null就结束并且出来了

接着进入getAttr函数
poc中我们将$this->data赋值为一个对象 因此return一个Request对象之后 就会退出该方法 出来


$relation变为了Request对象

调用$relation->visible() 并且参数为[calc,calc.exe] 而Requset类是没有visible函数的
因此会调用它的__call方法
一般PHP中的__call方法都是用来进行容错或者是动态调用,所以一般会在__call方法中使用
1 | __call_user_func($method, $args) |
我们跟进它
发现它调用 call_user_func_array方法 这可不是一个好方法:)
并且这里$hook是我们可控的 因此我们可以设计一个数组$hook= {“visable”=>”任意method”}

但是在这里array_unshift函数在之前的[calc,calc.exe]数组开头中插入Request对象


我们知道call_user_func_array函数会把第一个参数作为回调函数调用,第二参数是一个数组,并且将数组作为回调函数的参数

$this->hook为一个数组并且我们可控 然后取键名为visible的值
那么我们只能是(偷的)
1 | call_user_func_array([$obj,"任意方法"],[$this,任意参数]) |
那这里需要知道的是回调函数不光只是简简单单的普通函数
还可以是对象的方法 包括静态类方法

因此这里就可以理解上述中call_user_func_array中第一个参数函数格式为[$obj,"任意方法"]也就是对象的方法
而参数由于array_unshift函数的缘故第一个为$this也就是一个对象
$obj->$func($this,$argv)那么直接用这种方式去RCE 很难的拉
此时就要用到了Request类中一个特殊的功能就是过滤器filter
并且ThinkPHP中的多个RCE漏洞都是由于这个过滤器
所以我们可以去尝试覆盖filter方法去RCE
我们先来看看这个filter
1 | private function filterValue(&$value, $key, $filters) |
该方法调用了call_user_func 但是在这里$value参数不可直接控
继续寻找
发现input函数调用了这个filterValue

1 | public function input($data = [], $name = '', $default = null, $filter = '') |
但是这里参数仍旧不可控 不能直接使用
那么继续看看谁调用了input 套娃就完事了
那么这里是找到了param函数 不过参数仍旧不可控

1 | public function param($name = '', $default = null, $filter = '') |
继续寻找谁调用了param
找到了 isAjax函数

1 | public function isPjax($pjax = false) |
而isAjax函数中 我们可控$this->config['var_pjax']
意味着param函数中$name可控
意味着input函数中的$name可控
在这里 以上我们说什么可控 什么不可控应该怎么判断呢
可以直接看类属性 类中明确定义了某个属性 那么这个属性我们就可以重写来控制
而param函数可以获得$_GET数组 并且赋值给$this->param
1 | $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false)); |

跟进input

跟进getData
1 | protected function getData(array $data, $name) |
从GET数组中获取键名为Z1d10t的键值
并且返回值就是我们的命令

接着进入getFilter函数获取filter属性

在param函数中filter首先为空 然后在这里用$this->filter赋值 因此我们可以再poc中重新赋值为system

然后在$filter数组追加一个default 值为null 没大用 然后返回


最后进入filterValue函数

用array_pop弹出数组末尾的元素 因此只剩下了system

然后再调用call_user_func方法 完成RCE

ok 让我们再来梳理一下思路
这里我们可以从两个方向正向与反向去分析比较清晰
正向:
1 | \thinkphp\library\think\process\pipes\Windows.php - > __destruct() |
反向
1 | 前面由于参数不理想 我们没法愉快的执行函数 因此我们只好寻找其他方法 |
偷包浆的图 这个图思路太清晰了

修复
直接把__call()方法砍了 后面的链子也就断掉了
5.2.x版本分析
这里官方已经把Thinkphp5.2.x升级为Thinkphp6.0因此没法下载5.2.x的源码包
寄 网上找不到相关源码包
之后在6.x分析再回顾吧

可以看这位师傅的thinkphp5.1.x~5.2.x版本反序列化链挖掘分析 - 先知社区
5.0.x版本分析
环境搭建
- php7.3.4+Xdebug+thinkphp5.0.24+phpstudy+phpstorm
和上面一样准备一个二次反序列化入口 替换/application/index/controller/index.php

poc:(影响版本5.0.24和5.0.18,5.0.9不可用)
1 | <?php |
将生成的payload 在首先给input传参 (原谅这里为什么是thinkphp5.1 只要\thinkphp\base.php显示版本是正确的即可)

然后访问/public/a.php3b58a9545013e88c7186db11bb158c44.php
密码为ccc 可见是可以打通的

可以看一下我们写马上去的文件内容 可见不是正常的一句话 里面其实还包括了filterchain的知识点
一句话理解就是利用base64、iconv等编码组合构造出特定的php代码进而完成无需临时文件的RCE 并且可以绕过死亡函数

挖掘
首先我们的入口点还是Windows.php的析构函数 前面一部分的链子和5.1.x的差不多

继续跟进 到了removeFiles() 这里还是一样由于直接把对象当作字符串传入file_exists函数 因此导致会触发__toString()
但是和5.1.x的链子不同的是5.0.x的model类有__toSting() 因此不需要use 5.1.x的被trait修饰的Converison类用它的__toString()

继续跟进 到toJson()

然后进到了toArray()函数

这里有四个断点我们需要着重分析

首先这里$relation是通过parseName方法完成的 顺便提一嘴 这里调用方法方式是通过静态方法调用的
那这里我们需要知道$name 的值是通过遍历$this->append数组赋值的为getError

那这里有个疑惑就是我们poc中是给Pivot类的$this->append赋值的

为什么这里Model直接用呢 因为Model是Pivot的父类 因此父类可以直接调用子类的protected修饰的属性
ok 我们继续跟进分析一下
这里type=1进入if语句 直接返回getError

此时$relation为getError 继续到 method_exists函数
Model这个类是有getError函数的

因此会进入 这里的error可控 并且我们设置为了HasOne对象

同样poc中也是可以体现出来的

第一个断点至此先说到这里 我们继续跟进第二个断点
是对$value进行赋值

这里有一个很长的if条件语句的判断
1 | if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) |
我们一部分一部分来看
条件一:
之前我们5.1版本的时候在__toSring这一部分的时候是触发了__call方法,那么我们在这的目标也是去寻找合适的call方法,那么最终是找到了think\console\Output我们应该让这个方法返回一个output对象 这样等我们出去之后 执行$value->getAttr($attr)就会触发__call();额,因此这里value的值为$this-parent,所以我们需要赋值为一个output对象
条件二:
$modelRelation我们已经对它赋值了HasOne对象了

我们跟进isSelfRelation()方法 它是Relation类的一个方法

而OneToOne是它的子类 
HasOne是OneToOne的一个子类
因此我们控制HasOne->selfRelation为false即可
条件三:get_class($modelRelation->getModel()) == get_class($this->parent)
之前我们说到让$this->parent为一个Output对象即可 因此这里条件成立的就是让$modelRelation->getModel()返回一个output对象
我们跟进这个函数观察一下

那么思路就是看看哪个类使用了同名getModel函数 并且方法我们可控
那么最后找到是Query类

因此我们这里只需要$this->query为Query的一个对象即可
那么三个条件均满足 然后$value = $this->parent我们就出来了
接着进入第三个断点

这里$modelRelation是HasOne对象 调用它的getBindAttr方法 其实它没有 调用的是它的父类OneToOne的方法
返回一个数组["a"=>"admin"]

出来之后 我们对bindAttr数组做了遍历 取出了admin值 并且赋值给$attr 准备进入__call方法

value为Output对象 attr为admin

继续跟进

用array_unshift函数将方法名也就是getAttr加到参数数组中 最后为["getAttr","admin"]
然后用call_user_func_array()调用自己的block方法

跟进该方法 调用了writeln方法 并且值为我们当作参数传入的数组内容

继续跟进writeln 又调用了write方法

那么我们全局搜索看看谁调用了write函数 在Memcached类找到了合适的write方法
因此让Output类中$this->handler为Memecached对象即可

这里又调用了set方法 我们继续set方法
最后发现File类中调用了set方法 所以这里我们让Memcache类中的$this->handler为File对象即可

继续向下走 我们可以看到这里有直接写php🐎的语句 但是有死亡exit()我们可以利用编解码绕过 php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php
这里就不过多赘述了 参考:https://www.cnblogs.com/meng-han/p/16849600.html
然后还有file_put_contents() 危险函数

$filename通过getCacheKey函数获得的 我们跟进
这里的name为我们之前参数数组中两个值getAttr和admin拼接的 然后计算其md5值赋值为$name
为63ac11a7699c5c57d85009296440d77a

接着到$this->options['path'] 这里我们是可控的 也就是文件名我们可控 设置为我们的特殊编解码paylaod

文件最终名为$name 也就是之前计算的md5值 再加上$this->options['path']两部分构成的
为php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php
虽然此时我们文件名可控了但是$value为true 也就是文件内容$data我们不可控

继续分析这里会调用setTagItem这个函数 我们跟进观察

该函数又会调用一次set函数 那么此时$value的值我们是可控的 也就是传进来的$name也就是$filename

那么此时传入set的$name为$key=tag_c4ca4238a0b923820dcc509a6f75849b

但是这里还有一个问题 就是exit()死亡函数的绕过 我们写入文件的内容受到这个函数的影响
并且内容就是我们的文件名 因此只需要我们将文件名构造为特殊的可以吞掉exit()的编码即可

所以会生成两个文件 只有第二个文件内容才是我们想要的
并且第二个文件名为"php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php3b58a9545013e88c7186db11bb158c44.php"+md5(tag_c4ca4238a0b923820dcc509a6f75849b).php
产出的文件名为:a.php3b58a9545013e88c7186db11bb158c44.php
这里文件名也有讲究 我们可以看到我们的文件名应该是aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php3b58a9545013e88c7186db11bb158c44.php"+md5(tag_c4ca4238a0b923820dcc509a6f75849b).php这一坨才对
最后为什么只生成了我们想要的a.php3b58a9545013e88c7186db11bb158c44.php
我们将前面的一串base64字符整体看作一个目录,虽然没有,但是我们后面重新撤回了原目录,生成a.php3b58a9545013e88c7186db11bb158c44.php文件,从而就可以生成正常的文件名。
参考:探索php伪协议以及死亡绕过 - FreeBuf网络安全行业门户
再偷一份包浆整个流程图片 整理整理思路

至此分析完美撒花
结尾
不能只是一味的为了解题而去刷题不去深究真正的漏洞原因
看了很多厉害师傅的博客 深深的e住了
不把屎一样的东西搬出来抽象了 放在雨雀上自己看看就好了
这也是为什么停了博客一个月了qwq,深思中。。。
还有就是期末了 也e 要给时间复习了
参考:
ThinkPHP5.x反序列化漏洞全复现 - Boogiepop Doesn’t Laugh