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