0x00 前言
这篇文章主要是结合 thinkphp 5.0.x 两个rce :
(1)变量覆盖filter
(2)没有开启强制路由导致rce
来分析thinkphp 的路由
0x01 路由检测
首先要说的是$dispatch 调度信息,
类似于:
调度信息最后会传入App::exec() 中:
而exec 会根据调度信息的type ,去调用相应的函数去映射到具体的函数上去执行。
App.php 中run() 函数中,$dispatch 是通过 App.php 中的routeCheck() 函数返回的
App.php 中的routeCheck() 函数先得到path:
然后看config 中参数是否开启路由【默认都是开启的】,如果开启的,先看是否有路由缓存文件,没有的话,直接解析route.php 文件。[congfig 中rout_config_file 配置的]
-------
关于从路由配置文件route.php 中导入路由配置并且解析:
路由配置文件route.php 中允许两种方式,一种是直接 route::get(…) ,另一种是return 规则数组。
直接route::get() 的话就通过上面的Include 包含进来 ,但是route::get 也会走后面的Route::setRule : get() -> rule -> setRule ,到setRule 的时候type 就直接是GET了,而不是*
Route::import 是解析route.php 中return 的规则数组
关于Route::import
最后会调用 Route::registerRules 来注册路由 ; 当然里面还会先注册域名部署,变量规则,路由别名,资源路由。
通过registerRules 进入setRule 的时候,type 都是 *
registerRules 中会根据route.php 中的格式【对应的就是rules 数组】来进行路由注册,比如分组路由,直接setRule() 路由等等。
再跟进Route::setRule ,作用就是赋值Route::$rules 变量,结果类似于:
另外setRule 中还解析变量
另外可以看到328-336行,通过type 为 * 注册的(也就是在route.php 中通过return 注册的,都会在Route::$rules 注册上所有的方法(也就是快捷方式))
但真正的方法注册到的是option 中:
会调用Rout::check 函数进行路由检测:
App::routCheck()先导入路由配置,调用的是Route::import
然后调用Route::check 来进行路由检测,第二个参数是path 信息,【对应check 形参中的 $url 】
Route.php 中Route::check() 函数中先判断静态路由:
不是静态路由的话,调用checkRoute检查路由是否匹配规则
判断请求方法,然后通过方法赋值$rules 变量
(这也是变量覆盖那个rce 的触发点!!!🚩)
---- 1-------
变量覆盖那个rce 问题就出在\ $requet->method() 里面:
route检测的时候,为了获取请求方法,以进行路由匹配,调用Request::method(), 但是Request::method() 直接从$POST 中直接获取__method ,然后还会调用$this->{this->method}($_POST) ,这里如果传入__method为__construct() 就存在变量覆盖了。
其实method()这样做的原意是为了通过表单传入put ,delete 等方法,顺便获得put ,delete 的请求参数
但是没有对传入的’var_method’(__method)进行检测,所以就可以动态调用__construct() :
那么就存在变量覆盖了,可以覆盖掉Request:: 中的Request::$filter ,Request::$get 参数
POST http://localhost/tp/public/index.php?s=captcha
_method=__construct&filter[]=system&method=GET&get[]=whoami
-----1-------
然后调用Route::checkRoute() 检查路由是否匹配规则
checkRoute会依次遍历rules,对每个rule 进行检验:【可以看到911行就是判断是否是快捷路由,如果是的话通过Route::getRouteExpress 得到具体的 $item 】:
然后判断是否是分组路由,是分组路由再重新调用Route::checkRoute,不是的话直接rule调用checkRule 检测url 路由
Route::checkRule:
会调用Route::match() 去检测url 和路由规则是否匹配:
Rout::match() 函数 ,其实利用的就是正则匹配
如果匹配到了,会调用Rout::parseRule() 方法,把路由解析到具体的方法 ;这个方法的rule 就是前面Rout::match() 函数匹配到的路由的rule ,比如 :
自此,就返回了$dispatch 调度信息为某个模块的某个方法(type 见上图的parseRule 中的type 赋值,有上面5种type)
=> 对应的就是\thinkphp\thikphp5.0.22\vendor\topthink\think-captcha\src\CaptchaController.php
如果没有匹配到,会检查是否开启了强制路由,如果开启了则抛出路由无效的异常, 🚩
如果没有开启强制路由,则会调用Route::parseUrl() 进行控制器自动搜索
-----2-------
对于thinkphp 没有开启强制路由的rce [问题出在 App::module 没有对控制器名进行过滤,然后会调用Loader::controller 的时候就导致可以加载类似 think\app 这种controller(也就是这个app 类) ]
http://127.0.0.1/public/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
关键点在于 App::routeCheck 中:
如果没有匹配到路由,会检查是否开启了强制路由,如果开启了则抛出路由无效的异常,
如果没有开启强制路由,则会调用Route::parseUrl() 进行控制器自动搜索
Route::parseUrl :会返回 [‘module’,‘route’(array)(重新封装好的路由)]给调度器
parseUrl中会先调用Route::parseUrlPath 进行 path解析,来解析URL的pathinfo 参数和变量
然后会解析直接利用array_shift() 函数一一解析模块名,控制器名和方法名(也就是操作名):
然后没有开启debug 模式的时候,会调用App::exec() ,App:exec() 中会根据不同的调度类型,选择执行的函数,比如这里是module 类型,就会调用App::module() 方法
App::module() 方法 ,用来执行模块:
进行模块的一些配置初始化操作:
比如里面的模块初始化App::init($module),就是加载一些模块配置,数据库配置等等:
然后设置请求的控制器和操作并且监听 module_init:
然后会实例化控制器: 使用的是 Loader::controller() 方法[问题就出在这]:
然后设置操作名:
然后设置监听,同时调用App::invokeMethode() 来调用方法
invokeMethod() 里面会进行参数绑定,调用的是bindParams() 函数:
对于bindParams():有趣的是他在invokeMethod 之前也没有进行参数绑定,而是在invokeMethod的时候,才进行参数绑定。
(invokeMethod 的时候就没有传入参数)
【这里面调用了Requests::param() 方法】
成功调用:
0x03 其他: 关于参数绑定
既然上面谈到了参数绑定,所以决定再细说一下thinkphp 中参数绑定:
有两种参数传递方式: 一个是 url_parm_type 为 1 ,表示
按照解析顺序进行,另一种是0(默认),按照键值对解析。
374行到375行,先获取请求的变量,放到
v
a
r
s
数
组
中
:
然
后
先
判
断
调
用
的
函
数
参
数
的
调
用
个
数
是
不
是
大
于
0
,
是
的
话
继
续
进
行
,
否
则
返
回
参
数
为
[
]
然
后
251
行
,
得
到
函
数
的
反
射
参
数
数
组
然
后
253
行
,
得
到
函
数
的
反
射
参
数
名
,
然
后
263
行
如
果
是
u
r
l
p
a
r
a
m
t
y
p
e
为
1
,
则
判
断
vars 数组中: 然后先判断调用的函数参数的调用个数是不是大于0,是的话继续进行,否则返回参数为[] 然后251行,得到函数的反射参数数组 然后253行,得到函数的反射参数名,然后263行如果是url_param_type 为1 ,则判断
vars数组中:然后先判断调用的函数参数的调用个数是不是大于0,是的话继续进行,否则返回参数为[]然后251行,得到函数的反射参数数组然后253行,得到函数的反射参数名,然后263行如果是urlparamtype为1,则判断var 数组是否为空,不是空就赋值参数名;如果url_param_type 为0 ,则265行判断参数名是不是在$vars 数组中,如果在就赋值
$this->param() 中: $this->get(): 注意在路由匹配的时候unset 了 KaTeX parse error: Expected 'EOF', got '#' at position 248: …lor_FFFFFF,t_70#̲pic_center) ![在…args 数组,所以也可以看得出是顺序传入的(是数字形下标数组) ,比如形参有a,b,c ,如果只传入 a ,c 的话,args 中的结果也就是a,c 的值,所以这一步可能就会调用出错。
另外254行到261行,对于参数是类的话也可以进行解析(这个还有待研究,不知道怎么传类的),那么键值对的key 就是class ,value 就是 class
最后那个全局过滤好像并没有什么用: 就是在过滤的字符串后面加上一个空格:
0x04 变量覆盖的那个rce ,为什么需要captcha 这个路由
对于变量覆盖的那个rce ,既然在 路由检测的时候就以及覆盖掉了 filter 和 get,那为什么还是需要captcha 这个路由呢?
我们尝试直接这样打;
造成这个payload 不行的原因这样请求,$dispatch 的 type 会是module,type 为module 的话,在App::exec() 中会调用App::module() ,而在App::module() 方法中的544 行:
在这一步之前,我们可以看到filter 是成功覆盖成了 system 。
但是经过544 行,我们跟进:
可以看到这里会直接把$requests->filter 重新设置为 default_filter 也就是null:
而对应的captcha 的路由,我们看到在 thinkphp5.0.22\vendor\topthink\think-captcha\src\helper.php中:
可以看到这样使用 @ 定义路由的话,返回的dispath 中的 type 是method ,
而type 为method 的时候,469 行会先调用$request->param()
会先调用 $request->param() 方法, 而在这个方法中661行,会调用 $request->input() 方法
而我们知道input方法中 会对传入的第一个参数(data) 用 filter 来处理:
这样就造成了rce 。
当然type 为 controller 的时候也可以通过这样来rce:
所以只要存在 type 为 method 或者为 controller 的路由,就可以通过这样来rce 。
0x05 关于变量覆盖rce payload 的一些问题
POST http://localhost/tp/public/index.php?s=captcha
_method=__construct&filter[]=system&method=GET&get[]=whoami
(1) 需不需要 method= GET:
i . 先看5.0.0 版本:
可以看到如果没有传入method=GET 的话,在814行会执行:
$rules = self::$rules[$method];
而这个时候的$method = “__construct” ,所以就会报一个未定义索引的错误,导致退出。
ii. 再看 5.0.22 版本
在 Route.php 中的857 行和 858行 :
修改为了 :
$rules = isset(self::$rules[$method]) ? self::$rules[$method]:[];
这样就不会报错,程序继续正常运行。但是这样$rules 就会变为 [] , 会直接再后面Route::check() 直接返回 false,而不进入self::checkRoute()
所以最后App::routCheck() 中会走Route::parseUrl
这样返回的$dispatch的type 就会变为 module ,而之前也分析了,走module的话,会重新设置 filter 为默认的filter ,这样payload 就打不了了。
而且如果换成 method = POST 或者 PUT 也是不行的,因为captch 的路由规定了是get 路由:
所以总结来说是需要 method = GET 。
(2) 为什么payload 会执行两次:
因为$this->param()会把 $_POST 和 $this->get() 融合一次,所以filter 的时候data 就是:
array_walk_recursive() 会递归到深层的数组中去:
所以array_walk_recursive() 的时候就会调用两次 system(‘whoami’);
像这个payload 就只会执行一次 system(whoami)
_method=__construct&filter[]=system&method=GET&a=whoami
所以总结来说 get[]=whoami 这里的get[] 不是必须的,我们可以改为其他的变量。payload 也能正常的打。