xctf weiPHP && ThinkCMF pathinfo模式分析

前言

这篇文章不再去分析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

漏洞分析

整体调用链

image.png

这是调用到common_add方法的整个调用链,我们需要从程序入口点进行分析

程序入口

像TP这些框架的入口点都是index.php,获取到所有的传入的模块,控制器,方法以后,统一进行调度

这里调度有很多种方式:

  1. 使用call_user_func
  2. 使用call_user_func_array
  3. 使用反射

等等,不同的框架对这方面的实现都不尽相同,我们并不需要太过关注框架本身的启动方式,只要找对应的路由处理的函数即可

路由初始化

image.png

在入口点的run方法中,有对全局各种变量和环境进行初始化的函数,在这里,而在最后,有对框架路由功能的初始化

image.png

因为漏洞是在路由的时候触发的,我们需要重点关注路由的处理

进入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目录


image.png

可以看到,这里的路由可以直接通过Route类使用匿名方法的方式进行定义,框架首先加载了route/route.php文件中的路由规则

而route_annotation默认为false,所以不进入自动生成路由定义的分支

路由检测

image.png

在路由初始化结束以后,进行了路由检测的工作,获取对应的模块-控制器-方法

进入到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

image.png
  • 默认不是强制路由,进入$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的对象,在这个类的构造方法中,对各种属性进行了初始化

image.png

默认路由规则

继续进入到$dispatch = $this->routeCheck()->init();的init方法中,进行默认的解析规则


image.png

在parseUrlPath中对我们的pathinfo类型的url进行了解析

image.png
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];
    }
  1. 将URL中的|替换为/
  2. 然后对pathinfo的结果进行解析,进入到第二个elseif分支,将url根据/打散,将这个数组存入path中

最后的返回结果:


image.png

之后通过array_shift解析出来模块名,控制器名,方法名

image.png

再将之前pathinfo解析出来的参数进行解析

image.png

将对应的路由打包成一个数组,返回


image.png

路由调度的准备工作

框架已经获得了对应的控制器,接下来就是对路由的调度,主要由Module类负责

image.png

注意这一步的判断,这判断了$module的值是否在deny_module_list中,我们看一下deny_module_list默认是什么

image.png

可以看到,框架默认是禁止对common模块的访问的,用户不能直接调用common类中的方法,这些是框架内部使用的。

我们这里的module是coupon,所以不在黑名单中

image.png

这几步加载了模块目录,获取了控制器名和操作名,并且对Request类中的属性进行了更新

这几步主要是对接下来进行的调度做的一些准备工作

进行路由调度

image.png

在所有的准备工作结束以后,调用了$dispatch对象的run方法,进行路由调度

在判断是否有路由after之后的操作和数据自动验证以后,调用exec方法


image.png

第一步:实例化控制器

image.png
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这个类在命名空间中的位置


image.png

判断是否存在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类的实例

image.png

获取到action之后,判断了对于api实例,对应的action是否是可以调用的,我们的action是common_add这是在Base类里面定义的方法,也就是Api类的父类,是否可以通过is_callable的判断呢?

可以自己进行测试一下

可以看到,对于一个子类实例,它可以调用父类的public方法

所以我们可以调用Api父类的public属性的方法,来看一下Api类的继承关系

image.png

所以Api类可以调用ApiBase类,Base类,Controller类的公共方法,而Base类是在common模块下的,所以我们得以通过间接方式,绕过了之前的deny_module_list的限制

之后通过反射获取了该实例对应的common_add方法对象


image.png

现在我们有了common_add这个方法,那么我们怎么传入这个函数的参数呢?


image.png

框架调用了$this->request->param();方法获取输入的参数

image.png

之后通过invokeReflectMethod方法调用该方法,进入invokeReflectMethod方法中

public function invokeReflectMethod($instance, $reflect, $vars = [])
    {
        $args = $this->bindParams($reflect, $vars);

        return $reflect->invokeArgs($instance, $args);
    }

第一步先进行方法对象的参数绑定,我们的templateFile的值就是在这个时候进入到函数参数中,之后调用$reflect->invokeArgs方法调用这个方法

通过反射调用到了对应的方法,并且templateFile可控,导致了RCE

image.png

你可能感兴趣的:(xctf weiPHP && ThinkCMF pathinfo模式分析)