前言
这篇文章不再去分析common_add方法导致的RCE漏洞的原理,而是分析一下,为什么路由会到用户自定义控制器的父类中
要想知道为什么会到控制器的父类中,我们需要去分析MVC框架中是怎么做到控制器路由的,而路由到父类的方法中,就是框架在处理路由的时候发生的问题
payload
http://127.0.0.1/weiphp5.0/public/index.php/coupon/api/common_add?templateFile=/xxx/xxx/xxx/xx.x
漏洞分析
整体调用链
这是调用到common_add方法的整个调用链,我们需要从程序入口点进行分析
程序入口
像TP这些框架的入口点都是index.php,获取到所有的传入的模块,控制器,方法以后,统一进行调度
这里调度有很多种方式:
- 使用call_user_func
- 使用call_user_func_array
- 使用反射
等等,不同的框架对这方面的实现都不尽相同,我们并不需要太过关注框架本身的启动方式,只要找对应的路由处理的函数即可
路由初始化
在入口点的run方法中,有对全局各种变量和环境进行初始化的函数,在这里,而在最后,有对框架路由功能的初始化
因为漏洞是在路由的时候触发的,我们需要重点关注路由的处理
进入routeInit(App)函数
public function routeInit()
{
// 路由检测
$files = scandir($this->routePath);
foreach ($files as $file) {
if (strpos($file, '.php')) {
$filename = $this->routePath . $file;
// 导入路由配置
$rules = include $filename;
if (is_array($rules)) {
$this->route->import($rules);
}
}
}
if ($this->route->config('route_annotation')) {
// 自动生成路由定义
if ($this->appDebug) {
$suffix = $this->route->config('controller_suffix') || $this->route->config('class_suffix');
$this->build->buildRoute($suffix);
}
$filename = $this->runtimePath . 'build_route.php';
if (is_file($filename)) {
include $filename;
}
}
}
首先是对路由目录下文件进行包含,也就是框架的route目录
可以看到,这里的路由可以直接通过Route类使用匿名方法的方式进行定义,框架首先加载了route/route.php文件中的路由规则
而route_annotation默认为false,所以不进入自动生成路由定义的分支
路由检测
在路由初始化结束以后,进行了路由检测的工作,获取对应的模块-控制器-方法
进入到routeCheck方法
/**
* URL路由检测(根据PATH_INFO)
* @access public
* @return Dispatch
*/
public function routeCheck()
{
// 检测路由缓存
if (!$this->appDebug && $this->config->get('route_check_cache')) {
$routeKey = $this->getRouteCacheKey();
$option = $this->config->get('route_cache_option');
if ($option && $this->cache->connect($option)->has($routeKey)) {
return $this->cache->connect($option)->get($routeKey);
} elseif ($this->cache->has($routeKey)) {
return $this->cache->get($routeKey);
}
}
// 获取应用调度信息
$path = $this->request->path();
// 是否强制路由模式
$must = !is_null($this->routeMust) ? $this->routeMust : $this->route->config('url_route_must');
// 路由检测 返回一个Dispatch对象
$dispatch = $this->route->check($path, $must);
if (!empty($routeKey)) {
try {
if ($option) {
$this->cache->connect($option)->tag('route_cache')->set($routeKey, $dispatch);
} else {
$this->cache->tag('route_cache')->set($routeKey, $dispatch);
}
} catch (\Exception $e) {
// 存在闭包的时候缓存无效
}
}
return $dispatch;
}
- 首先进行路由缓存的检查,因为开的是debug模式,所以这一步直接跳过
- 获取应用调度信息
进入$this->request->path()函数中
path函数定义在Request类中
public function path()
{
if (is_null($this->path)) {
$suffix = $this->config['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;
}
从我们的payload中,可以看到我们的路由格式为pathinfo,所以在path这个函数中,通过$this->pathinfo()函数获取到了对应的pathinfo的值,也就是coupon/api/common_add,所以最后返回了coupon/api/common_add
- 默认不是强制路由,进入$this->route->check函数
public function check($url, $must = false)
{
// 自动检测域名路由
$domain = $this->checkDomain();
$url = str_replace($this->config['pathinfo_depr'], '|', $url);
$completeMatch = $this->config['route_complete_match'];
$result = $domain->check($this->request, $url, $completeMatch);
if (false === $result && !empty($this->cross)) {
// 检测跨域路由
$result = $this->cross->check($this->request, $url, $completeMatch);
}
if (false !== $result) {
// 路由匹配
return $result;
} elseif ($must) {
// 强制路由不匹配则抛出异常
throw new RouteNotFoundException();
}
// 默认路由解析
return new UrlDispatch($this->request, $this->group, $url, [
'auto_search' => $this->autoSearchController,
]);
}
首先检测域名路由,并且将解析出来的pathinfo中的/用|来代替,因为我们没有配置域名路由,所以$result的结果是false,之后检测跨域路由,同样因为没有配置,$result的结果仍然为false
则会最后进入到默认路由解析,最后返回了一个UrlDispatch的对象,在这个类的构造方法中,对各种属性进行了初始化
默认路由规则
继续进入到$dispatch = $this->routeCheck()->init();的init方法中,进行默认的解析规则
在parseUrlPath中对我们的pathinfo类型的url进行了解析
public 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);
} elseif (false !== strpos($url, '=')) {
// 参数1=值1&参数2=值2...
$path = [];
parse_str($url, $var);
} else {
$path = [$url];
}
return [$path, $var];
}
- 将URL中的|替换为/
- 然后对pathinfo的结果进行解析,进入到第二个elseif分支,将url根据/打散,将这个数组存入path中
最后的返回结果:
之后通过array_shift解析出来模块名,控制器名,方法名
再将之前pathinfo解析出来的参数进行解析
将对应的路由打包成一个数组,返回
路由调度的准备工作
框架已经获得了对应的控制器,接下来就是对路由的调度,主要由Module类负责
注意这一步的判断,这判断了$module的值是否在deny_module_list中,我们看一下deny_module_list默认是什么
可以看到,框架默认是禁止对common模块的访问的,用户不能直接调用common类中的方法,这些是框架内部使用的。
我们这里的module是coupon,所以不在黑名单中
这几步加载了模块目录,获取了控制器名和操作名,并且对Request类中的属性进行了更新
这几步主要是对接下来进行的调度做的一些准备工作
进行路由调度
在所有的准备工作结束以后,调用了$dispatch对象的run方法,进行路由调度
在判断是否有路由after之后的操作和数据自动验证以后,调用exec方法
第一步:实例化控制器
public function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
{
list($module, $class) = $this->parseModuleAndClass($name, $layer, $appendSuffix);
if (class_exists($class)) {
return $this->make($class, true);
} elseif ($empty && class_exists($emptyClass = $this->parseClass($module, $layer, $empty, $appendSuffix))) {
return $this->make($emptyClass, true);
}
throw new ClassNotFoundException('class not exists:' . $class, $class);
}
首先,通过模块和控制器,找到对应api这个类在命名空间中的位置
判断是否存在Api这个类,如果存在的话,调用make方法
在make方法中,调用了invokeClass方法
public function invokeClass($class, $vars = [])
{
try {
$reflect = new ReflectionClass($class);
if ($reflect->hasMethod('__make')) {
$method = new ReflectionMethod($class, '__make');
if ($method->isPublic() && $method->isStatic()) {
$args = $this->bindParams($method, $vars);
return $method->invokeArgs(null, $args);
}
}
$constructor = $reflect->getConstructor();
$args = $constructor ? $this->bindParams($constructor, $vars) : [];
return $reflect->newInstanceArgs($args);
} catch (ReflectionException $e) {
throw new ClassNotFoundException('class not exists: ' . $class, $class);
}
}
通过ReflectionClass获取到对应class的反射类
通过反射类判断是类中有__make函数
最后获取到Api类的构造方法,通过反射获取到Api类的实例
获取到action之后,判断了对于api实例,对应的action是否是可以调用的,我们的action是common_add这是在Base类里面定义的方法,也就是Api类的父类,是否可以通过is_callable的判断呢?
可以自己进行测试一下
可以看到,对于一个子类实例,它可以调用父类的public方法
所以我们可以调用Api父类的public属性的方法,来看一下Api类的继承关系
所以Api类可以调用ApiBase类,Base类,Controller类的公共方法,而Base类是在common模块下的,所以我们得以通过间接方式,绕过了之前的deny_module_list的限制
之后通过反射获取了该实例对应的common_add方法对象
现在我们有了common_add这个方法,那么我们怎么传入这个函数的参数呢?
框架调用了$this->request->param();方法获取输入的参数
之后通过invokeReflectMethod方法调用该方法,进入invokeReflectMethod方法中
public function invokeReflectMethod($instance, $reflect, $vars = [])
{
$args = $this->bindParams($reflect, $vars);
return $reflect->invokeArgs($instance, $args);
}
第一步先进行方法对象的参数绑定,我们的templateFile的值就是在这个时候进入到函数参数中,之后调用$reflect->invokeArgs方法调用这个方法
通过反射调用到了对应的方法,并且templateFile可控,导致了RCE