拖了好久的tp框架,是时候开始学习了
ThinkPHP5.0.24 反序列化漏洞探索
前言
由于本篇文章是tp框架学习计划的开头,为了方便后续其他tp漏洞学习,内容包括一些前置知识以及tp开发手册。毕竟安全和开发密不可分,打好基础对整个tp框架学习起到非常大的帮助。
前置知识
命名空间&子命名空间
namespace实际上是命名空间,我们可以把namespace理解为一个单独的空间,事实上它也就是一个空间而已,子命名空间那就是空间里再划分几个小空间,我们通过下面demo来具体了解一下
1 |
|
运用namespace定义了命名空间animal以及三个子空间cat,dogA,dogB
,并且编写三个类。我们来看下面几种运用命名空间的方式
如果我们直接进行实例化
new dog();
,那么输出结果为B:dogdogdog
。这是因为如果不选择命名空间的话,就直接是最后的命名空间namespace animal\dogB
直接对命名空间对应的类实例化,或者先规定命名空间然后直接实例化
1
2
3
4
5
6//直接实例化
new \animal\dogA\dog();
//先规定再实例化
use animal\dogA;
new dogA\dog();use
在这里和include、require有点像,就是在当前命名空间引入其他命名空间的别名,比如use animal\dogA as alias
其中的alias就是animal的别名,然后我们就可以用这个别名来调用1
2use animal\dogA as alias;
new alias\dog();
所以当我们在拉反序列化链子的时候 ,除了namespace
当前类的命名空间,还要use
下一个类的命名空间 + \类名
类的继承
直接来看下面demo
1 |
|
运行结果如下
我们通过这个demo可以发现。在php中类的继承和java中类似,子类可以继承父类,子类也可以覆写父类的方法。当然子类能利用parent::
关键字访问父类被覆盖的方法
trait修饰符
使得被修饰的类可以进行复用,增加了代码的可复用性,使用这个修饰符就可以在一个类包含另一个类
demo如下
1 |
|
运行结果
test是一个用trait修饰的类,所以我们只要在rev1ve类中use了test这个类,我们就可以调用其中的方法,类似于类的继承
我们看下面有趣的demo
test.php
1 | <?php |
test1.php
1 | <?php |
ThinkPHP开发手册
官方文档:https://www.thinkphp.cn/doc
基础
命名规范
函数和类、属性命名
- 类的命名采用驼峰法(首字母大写),例如
User
、UserType
,默认不需要添加后缀,例如UserController
应该直接命名为User
; - 函数的命名使用小写字母和下划线(小写字母开头)的方式,例如
get_client_ip
; - 方法的命名使用驼峰法(首字母小写),例如
getUserName
; - 属性的命名使用驼峰法(首字母小写),例如
tableName
、instance
; - 以双下划线“__”打头的函数或方法作为魔术方法,例如
__call
和__autoload
;
常量和配置
- 常量以大写字母和下划线命名,例如
APP_PATH
和THINK_PATH
; - 配置参数以小写字母和下划线命名,例如
url_route_on
和url_convert
;
目录结构
可以在README.md查看
1 | www WEB部署目录(或者子目录) |
架构
基础概念
ThinkPHP5.0
应用基于MVC
(模型-视图-控制器)的方式来组织,我们首先要了解一下几个概念
入口文件
用户请求的PHP文件,负责处理一个请求(注意,不一定是URL请求)的生命周期,最常见的入口文件就是index.php
,有时候也会为了某些特殊的需求而增加新的入口文件,例如给后台模块单独设置的一个入口文件admin.php
或者一个控制器程序入口think
都属于入口文件。
应用
应用在ThinkPHP
中是一个管理系统架构及生命周期的对象,由系统的 \think\App
类完成,应用通常在入口文件中被调用和执行,具有相同的应用目录(APP_PATH
)的应用我们认为是同一个应用,但一个应用可能存在多个入口文件。
应用具有自己独立的配置文件、公共(函数)文件。
模块
一个典型的应用是由多个模块组成的,这些模块通常都是应用目录下面的一个子目录,每个模块都有自己独立的配置文件、公共文件和类库文件。
5.0支持单一模块架构设计,如果你的应用下面只有一个模块,那么这个模块的子目录是可以省略,并且在应用配置文件中修改'app_multi_module' => false
控制器
每个模块拥有独立的MVC
类库及配置文件,一个模块下面有多个控制器负责响应请求,而每个控制器其实就是一个独立的控制器类。控制器主要负责请求的接收,并调用相关的模型处理,并最终通过视图输出。
5.0的控制器类比较灵活,可以无需继承任何基础类库。一个典型的Index
控制器类如下:
1 | namespace app\index\controller; |
操作
一个控制器包含多个操作(方法),操作方法是一个URL访问的最小单元。
下面是一个典型的Index
控制器的操作方法定义,包含了两个操作方法:
1 | namespace app\index\controller; |
操作方法可以不使用任何参数,如果定义了一个非可选参数,则该参数必须通过用户请求传入,如果是URL请求,则通常是$_GET
或者$_POST
方式传入。
URL访问
URL设计
ThinkPHP5.0
在没有启用路由的情况下典型的URL访问规则是:
1 | http://thinkphp5/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...] |
采用的是PATH_INFO
访问地址,其中PATH_INFO
的分隔符是可以设置的。
如果不支持PATH_INFO
的服务器可以使用兼容模式访问如下:
1 | http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...] |
必要的时候,我们可以通过某种方式,省略URL里面的模块和控制器。
URL大小写
默认情况下,URL
是不区分大小写的,也就是说 URL
里面的模块/控制器/操作名会自动转换为小写,控制器在最后调用的时候会转换为驼峰法处理。例如:
1 | http://localhost/index.php/Index/Blog/read |
如果访问下面的地址
1 | http://localhost/index.php/Index/BlogTest/read |
在这种URL不区分大小写情况下,如果要访问驼峰法的控制器类(即控制器blog的test类),则需要使用:
1 | http://localhost/index.php/Index/blog_test/read |
注:模块名和操作名会直接转换为小写处理。
模块设计
目录结构
1 | ├─application 应用目录(可设置) |
遵循ThinkPHP5.0
的命名规范,模块目录全部采用小写和下划线命名。
其中common
模块是一个特殊的模块,默认是禁止直接访问的,一般用于放置一些公共的类库用于其他模块的继承。
模块类库
一个模块下面的类库文件的命名空间统一以app\模块名
开头,例如:
1 | // index模块的Index控制器类 |
其中app
可以通过定义的方式更改,例如我们在应用配置文件中修改:
1 | 'app_namespace' => 'application', |
那么,index模块的类库命名空间则变成:
1 | // index模块的Index控制器类 |
命名空间相关内容可以参考前文的前置知识
单一模块
如果你的应用比较简单,只有唯一一个模块,那么可以进一步简化成使用单一模块结构,方法如下:
首先在应用配置文件中定义:
1 | // 关闭多模块设计 |
然后,调整应用目录的结构为如下:
1 | ├─application 应用目录(可设置) |
URL访问地址变成
1 | http://serverName/index.php(或者其它应用入口)/控制器/操作/[参数名/参数值...] |
同时,单一模块设计下的应用类库的命名空间也有所调整,例如:
原来的
1 | app\index\controller\Index |
变成
1 | app\controller\Index |
配置
配置格式
ThinkPHP框架中默认所有配置文件的定义格式均采用返回PHP数组的方式
格式:
1 | //项目配置文件 |
注:二级参数配置区分大小写
配置加载
在ThinkPHP中,一般来说应用的配置文件是自动加载的,加载的顺序是:
1 | 惯例配置->应用配置->模式配置->调试配置->状态配置->模块配置->扩展配置->动态配置 |
以上是配置文件的加载顺序,因为后面的配置会覆盖之前的同名配置(在没有生效的前提下),所以配置的优先顺序从右到左。
主要的配置:
惯例配置:ThinkPHP/Conf/convention.php
按照大多数的使用对常用参数进行了默认配置
应用配置:Application/Common/Conf/config.php
调用所有模块之前都会首先加载的公共配置文件
模块配置:Application/当前模块名/Conf/config.php
每个模块会自动加载自己的配置文件
动态配置:在具体的操作方法里面,对某些参数进行动态配置
C('参数名称','新的参数值')
读取配置
无论何种配置文件,定义了配置文件之后,都会统一使用系统提供的C方法(config)来读取已有的配置,这个方法可以在任何地方读取任何配置
用法:C('参数名称')
例如,读取当前的URL模式配置参数:
1 | $model = C('URL_MODEL'); |
注:配置参数名称中不能含有 “.” 和特殊字符,允许字母、数字和下划线
扩展配置
1 | // 加载扩展配置文件 |
假设user.php
和db.php
分别用于用户配置和数据库配置
其中公共配置的加载在Application/Common/Conf/user.php
和Application/Common/Conf/db.php
框架引导start.php
thinkphp为单程序入口,这是 mvc 框架的特征,程序的入口在public目录下的index.php
1 | // 定义应用目录 |
require
引入 thinkphp 的start.php
1 |
|
跟进到base.php,在base.php(thinkphp/base.php)
中定义了一些常量,比如ROOT_PATH
、RUNTIME_PATH
、LOG_PATH
等等,然后引入Loader
类来自动加载
1 | 位于thinkphp/base.php:38 |
然后在下面通过.env
文件 putenv 环境变量,接下来都是加载一些配置文件
最后配置完返回 thinkphp/start.php
启动程序
1 | //执行应用 |
ThinkPHP5.0.x反序列化链
环境搭建
thinkphp5.0.24
源码下载链接:http://m.lxywzjs.com/wap/article/index/artid/200.html
这里选择的是phpstudy上搭建,版本为PHP7.34+Xdebug+thinkphp5.0.24
创建好网站后,我们要准备一个二次反序列化入口
修改application\index\controller\Index.php
1 |
|
然后就是修改compose.json为5.0.24以及php版本
修改好上述内容后把下载好的文件夹内所有内容放到刚刚搭建网站的根目录
启动web服务访问/public
,若出现以下界面说明搭建成功
POC
1 |
|
执行payload后,会写入两个php文件
访问第二个a.php3b58a9545013e88c7186db11bb158c44.php
文件,POST参数为ccc直接命令执行即可
POP链分析
全局搜索__destruct()
方法,选择thinkphp\library\think\process\pipes\Windows.php
入口是windows的析构方法
继续跟进removeFile()
存在file_exists()
函数检查文件是否存在,而变量filename是字符串(也就是对filename的路径进行查找)
这里会有对象转换成字符串触发__toString()
方法,全局搜索一下
参考其他师傅的利用链,选择的是Model.php,跟进一下
可以看到调用toJson()
方法,继续跟进
这里进行return的时候调用toArray()
方法,继续跟进
前面代码大概就是递归处理模型的关联对象,重点往下看
首先我们要保证$this→append
数组不为空
其次在遍历数组时,要进入第三个else语句中,需要满足
1 | is_array($name) // 值不为数组 |
进入到else语句后,我们跟进下$relation = Loader::parseName($name, 1, false);
的parseName()
方法
这里的type值为1,然后将下划线分隔的字符串转换为驼峰式命名,并根据 $ucfirst
决定首字母的大小写。
就是简单对传入参数进行转换,对正常字符串没有影响
回到Model.php,进入if语句method_exists()
判断类方法是否存在
1 | if (method_exists($this, $relation)) { |
而这里$relation
往前推就是$name
我们在第899行下断点,不难发现$name
赋值为getError
我们搜索一下getError方法,作用就是返回值
也就是说我们的POC利用的是Model.php存在的类方法getError()
当if判断为真后,对$modelRelation
进行了赋值,在这里是通过$this->$relation()
,也就是$this->$getError()
完成的
所以返回值可控即$modelRelation
可控,我们将其设置为了HasOne对象,至于为什么我们在后面进行分析
我们继续往下看,是否$value
也可控
跟进一下getRelationData()
注意这里的if语句
1 | if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) |
如果为真则执行$value = $this->parent;
也就是$value
由$parent
决定
那么需要满足if三个条件
$this->parent
!$modelRelation->isSelfRelation()
get_class($modelRelation->getModel()) == get_class($this->parent))
第一个条件
我们一个个看,先在902行下断点,调试发现
POC将$parent
指向的是think\console\Output
类,之所以选择think\console\Output
类是因为这是我们找到其存在合适的__call()
方法,存在call_user_func_array
函数可以进一步getshell
回到Model.php,那么我们在最后执行的时候,让$value
指向该类就可以触发__call()
方法
1 | $item[$key] = $value ? $value->getAttr($attr) : null; |
第二个条件
我们跟进一下isSelfRelation()
,注意到是返回$selfRelation
我们如何让!$modelRelation->isSelfRelation()
成立呢
我们前面提到了将$modelRelation
设置为了HasOne对象,我们跟进一下
HasOne类是OneToOne类的子类,而OneToOne类是抽象类无法生成实例
不过发现OneToOne类又是Relation类的子类
所以$selfRelation
可控,那么我们将其设为false即可
也就是POC中HasOne类对$selfRelation
赋值,然后isSelfRelation()
为false使得第二个条件成立
1 | namespace think\model\relation{ |
第三个条件
这里要求返回的对象的类名相同
1 | get_class($modelRelation->getModel()) == get_class($this->parent)) |
$modelRelation->getModel()
就指的是Hasone::getModel()
,因为Hasone类是Relation类的子类,当然继承它的getModel()
方法
这里按住ctrl点击跟进,到thinkphp\library\think\db\Query.php
所以左侧get_class获得的类名由$model
决定,而右边我们前面也分析过$parent
指向output类
所以我们只需要让$model
的值为output对象即可
当然$query
也要指向thinkphp\library\think\db\Query.php
的getModel()
$modelRelation
和$value
分析完后我们继续往下看
if语句判断是否存在getBindAttr()
类方法
虽然HasOne类并没有,不过它继承了父类的getBindAttr()
所以if为真,执行$bindAttr = $modelRelation->getBindAttr();
不过要注意$bindAttr
定义的时候是protected $bindAttr = [];
也就是数组格式
我们在第904行下断点,可以发现赋值$bindAttr=['a'=>'admin'];
赋值后到第二个if语句,很明显if为真,然后遍历$bindAttr
赋值给$key => $attr
,因为$key=a
所以保持不变。继续到第三个if语句,$data
不存在a键名所以执行else语句,$value
为真执行$value->getAttr($attr)
触发__call()
方法
为什么可以触发
__call()
方法我们再理一下思路,$value
是由$parent
决定并且可控,前面提到我们将$parent
指向Output类的,而Output类不存在getAttr()方法,所以就触发Output类的__call()
方法达成我们目的
继续跟进到__call()
方法
判断$method
是否在$styles
数组里,如果if为真执行array_unshift($args, $method);
把$method
拼接到$args
数组的头部,我们可以下断点看看
然后return执行call_user_func_array()
函数调用block方法,跟进一下
调用writeIn()
方法,参数为<getAttr>admin</getAttr>
,这是上面2个变量拼贴来的
跟进writeIn()
方法
调用write()
方法,参数为<getAttr>admin</getAttr>
,另外两个分别为true,0
继续跟进write()
方法
这里最终还是调用write()
方法,我们全局搜索一下。最终在thinkphp\library\think\session\driver
路径下找到Memcache.php
和Memcached.php
存在利用点。不过这俩文件还是有点区别,在建立连接的时候Memcached.php
使用setSaslAuthData()
对身份进行认证
因此我们选择利用thinkphp\library\think\session\driver\Memcache.php
那么我们前面write()
的handle指向Memcache类就行
注:这里handle指向Memcached类也行,前面一通分析其实跟身份验证没啥关系,因为那是打开session的时候做的事,我们调用的
write()
方法是在写入session的时候做的,所以不管哪个类都能写webshell
跟进到Memcache.php的write()
方法
调用set方法,全局搜索一下找到thinkphp\library\think\cache\driver\File.php
存在file_put_contents()
可以写入文件,跟进一下发现存在死亡exit()
,用base64编码绕过
我们先看$filename
是如何赋值的,跟进getCacheKey()
方法
这里我们对cache_subdir
和prefix
进行赋值,然后跳过前面两个if语句到最后进行拼接
1 | $filename = $this->options['path'] . $name . '.php'; |
我们在第72行下断点,调试发现$name
的值为前面出现过的<getAttr>admin</getAttr>
对<getAttr>admin</getAttr>
进行MD5加密后,与$this->options['path']
和.php
进行拼接
得到$filename
为
1 | php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php |
虽然这里文件名可控,但是$data
并不可控($value
值为true),也就是说即使创建了文件也不是我们的马
回到set()
方法继续往下看,跟进setTagItem()
方法
我们赋值tag为1,然后$key
为tag拼接MD5加密过的字符1,由于$key
缓存并不存在所以执行else语句
$name
赋值给$value
,然后再次调用set()
方法
这里再次调用该方法后,传递的
$value
和$name
的值是一样的,我们可以看看到底发生了什么变化
对于file_put_contents($filename, $data);
中的$filename
来说还是不变
而$data
我们可以在第159行下断点看看,发现经过拼接后变成如下代码
1 | <?php |
我们注意到存在和$filename
一样的值,这也是绕过死亡函数的变种
类似于如下形式,具体绕过原理就不再赘述
1 | file_put_contents($content,"<?php exit();".$content); |
这个写的第二个文件就是我们实现RCE的马,所以其实整个过程是写了两个php文件
写的路径就在根路径下,shell文件名为a.php+md5(tag_c4ca4238a0b923820dcc509a6f75849b)+.php
以上就是整个POP链的调用分析
POP链调用过程图
总结
对于初学者来说,审这么又臭又长的代码确实是件非常具有挑战的事。除了本文讲到的前置知识和开发手册的内容外,还需要对反序列化基本概念非常了解以及绕过死亡函数的变种形式等等。以后有时间也会继续复现tp框架的其他漏洞。
参考文章
https://c1oudfl0w0.github.io/blog/2023/10/24/ThinkPHP%E6%BC%8F%E6%B4%9E%E5%A4%8D%E7%8E%B0/