thinkphp-RCE漏洞分析


前言

2018年12月10日中午,thinkphp5被爆出了5.x系列所有版本存在被getshell的高风险漏洞,2019年1月11日,又爆出了5.0.x系列所有版本存在RCE,2019年1月15日,正当我在分析1月11日的时候,有爆出了5.1~5.2版本代码执行(我的天),下面是分析1月11日漏洞的分析笔记。如有错误,欢迎师傅们斧正。
image.png

漏洞复现

本地环境采用的是THINKPHP 5.0.22+PHP Version 7.2.1+apache。先上两张poc执行之后的界面。
image.png
image.png

这里并没有开启debug模式,其实原理漏洞利用链大体相同。只是开启debug的时候,利用链提前了而已。

漏洞分析[1]

从最开始的入口开始分析。

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
30
31
think/App.php

public static function run(Request $request = null)
{
$request = is_null($request) ? Request::instance() : $request;

try {
$config = self::initCommon();
~~~~~

$request->filter($config['default_filter']);

~~~~~
$dispatch = self::$dispatch;

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

// 记录当前调度信息
$request->dispatch($dispatch);

~~~~~
$data = self::exec($dispatch, $config);
} catch (HttpResponseException $exception) {
$data = $exception->getResponse();}

~~~~~
return $response;
}

由于代码太长,这里只列出了重要代码,可以看到,在函数刚开始实例化了一个Request类,并赋值给$request。接着弄了$config变量,更新了$request->filter的值,默认为空。在进入$dispatch = self::routeCheck($request, $config);之前各变量入下图所示。

进入routeCheck()函数,下面是routeCheck函数源码

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
App.php\routeCheck


public static function routeCheck($request, array $config)
{
$path = $request->path();
$depr = $config['pathinfo_depr'];
$result = false;

// 路由检测
$check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on'];
if ($check) {
// 开启路由
if (is_file(RUNTIME_PATH . 'route.php')) {
// 读取路由缓存
$rules = include RUNTIME_PATH . 'route.php';
is_array($rules) && Route::rules($rules);
} else {
$files = $config['route_config_file'];
foreach ($files as $file) {
if (is_file(CONF_PATH . $file . CONF_EXT)) {
// 导入路由配置
$rules = include CONF_PATH . $file . CONF_EXT;
is_array($rules) && Route::import($rules);
}
}
}

// 路由检测(根据路由定义返回不同的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;
}

image.png
image.png

这里是调用了Route::check进行路由检测,先给出结果,为了更详细的分析得出$result,我们进去Route::check函数。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Route.php\check

public static function check($request, $url, $depr = '/', $checkDomain = false)
{
//检查解析缓存
if (!App::$debug && Config::get('route_check_cache')) {
$key = self::getCheckCacheKey($request);
if (Cache::has($key)) {
list($rule, $route, $pathinfo, $option, $matches) = Cache::get($key);
return self::parseRule($rule, $route, $pathinfo, $option, $matches, true);
}
}

// 分隔符替换 确保路由定义使用统一的分隔符
$url = str_replace($depr, '|', $url);

if (isset(self::$rules['alias'][$url]) || isset(self::$rules['alias'][strstr($url, '|', true)])) {
// 检测路由别名
$result = self::checkRouteAlias($request, $url, $depr);
if (false !== $result) {
return $result;
}
}
$method = strtolower($request->method());
// 获取当前请求类型的路由规则
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
// 检测域名部署
if ($checkDomain) {
self::checkDomain($request, $rules, $method);
}
// 检测URL绑定
$return = self::checkUrlBind($url, $rules, $depr);
if (false !== $return) {
return $return;
}
if ('|' != $url) {
$url = rtrim($url, '|');
}
$item = str_replace('|', '/', $url);
if (isset($rules[$item])) {
// 静态路由规则检测
$rule = $rules[$item];
if (true === $rule) {
$rule = self::getRouteExpress($item);
}
if (!empty($rule['route']) && self::checkOption($rule['option'], $request)) {
self::setOption($rule['option']);
return self::parseRule($item, $rule['route'], $url, $rule['option']);
}
}

// 路由规则检测
if (!empty($rules)) {
return self::checkRoute($request, $rules, $url, $depr);
}
return false;
}

调用method方法之前,在vendor\topthink\think-captcha\src\helper.php添加了路由
image.png

最关键的一步。

1
$method = strtolower($request->method());

image.png
这里调用了$request->method方法,而这个方法也是此次漏洞的根本所在点。同样,我们跟踪这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}


取得$_POST[‘_method’]的值并将其赋值给$this->method,然后动态调用$this->{$this->method}($_POST)。这意味着攻击者可以调用该类任意函数并以$_POST作为第一个参数。如果动态调用__construct函数,则会导致代码执行。

而__construct函数源码如下:

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

// 保存 php://input
$this->input = file_get_contents('php://input');
}

由于$options参数可控,攻击者可以覆盖该类的filter属性、method属性以及get属性的值。
覆盖之后变为

1
2
3
4
5
method:"get"
filter: array(1)
0: "system"
get: array(1)
0: "whoami"



因此得到调用了$request->method()之后是的$method=”get”

image.png

并把method作为键值,此时得到rules,这也是能过路由检测的原因,接着最后返回给$result,赋值给$dispatch

最后会执行exec()函数,$dispatch,$config作为参数,同样,跟进

1
$data = self::exec($dispatch, $config);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
case 'redirect': // 重定向跳转
$data = Response::create($dispatch['url'], 'redirect')
->code($dispatch['status']);
break;

~~~~

case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;

image.png

该分支调用Request类的param方法

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


当$this->mergeParam为空(默认为空)时,调用this->method(true),返回为”get”
image.png

后面有一个array_merge函数,这里会调用$this->get(false),同样跟踪。

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


这里函数末尾调用了$this->input函数,并将$this->get传入,而$this->get的值是攻击者可控的,在上面已经说了是一个whoami,同样,跟踪$this->input函数

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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];
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}

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

if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}

if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}

由于这里name为空,直接返回this->get
image.png
同样,this->route也是直接返回。
最后进入param函数里面的input函数,此时的$this->param如图,但此时name不是false
image.png
image.png
接着调用 $this->getFilter赋值给filter,由于此时data是数组
image.png

接着进入filterValue()

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
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, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $this->filterExp($value);
}

在call_user_func函数的调用中,$filter可控,$value可控。因此,可致代码执行。

漏洞分析[2]

这个poc其实原理相同,只是在变量覆盖的时候覆盖的不一样,这里覆盖了server

而在调用Request类的param方法中$method = $this->method(true);
image.png
调用了server函数,然后把this->server跟name作为参数传入input
image.png
然后是的$data值读取出来
image.png
由于此时$data不是数组,但是一样会进去filterValue函数,同样达到rce的目的
image.png

漏洞调用链

1
param -> method -> input -> getFilter -> filterValue->RCE

补丁分析

https://github.com/top-think/framework/commit/4a4b5e64fa4c46f851b4004005bff5f3196de003

可以看到这里有做一个白名单处理。

THE END

好气啊(如图)!!!!不过今天很开心认识了Mang0师傅,同时也挂了友链,师傅给我的建议是可以开始代码审计了,同时也给了一点资源,这里先谢谢师傅啦,明天开始就是一段代码审计的旅途了,希望2019年最好能挖一个CVE,CNVD,好好学习,努力搞钱嘿嘿嘿。(想起明天开始要搞美赛了,噶油)
image.png

-------------本文结束感谢您的阅读-------------