8827太阳集团ADLab:ThinkPHP5远程代码执行缝隙分析

颁布功夫 2018-12-11
缝隙介绍

2018年12月9日,, ,,,,,,ThinkPHP团队颁布了一个补丁更新,, ,,,,,,建复了一处由于路由解析缺点导致的代码执行缝隙。。。。。。该缝隙风险水平极度高,, ,,,,,,默认环境配置即可导致远程代码执杏祝。。。。。经过8827太阳集团ADLab安全钻研员对ThinkPHP的56个幼版本的源码分析和验证,, ,,,,,,确定具体受影响的版本为:


ThinkPHP 5.0.5-5.0.22

ThinkPHP 5.1.0-5.1.30


缝隙复现


本地环境选取ThinkPHP 5.1.29+PHP7+Apache进行复现。。。。。。装置环境后直接接见POC给定的URL即可执行phpinfo(),, ,,,,,,如图所示。。。。。。

8827太阳集团(Macau)股份有限公司-Official website


缝隙分析


以5.1.29版本进行分析,, ,,,,,,首先看取路由的函数pathinfo:

library/think/Request.php:678

public function pathinfo()
    {
        if (is_null($this->pathinfo)) {
            if (isset($_GET[$this->config['var_pathinfo']])) {
                // 判断URL里面是否有兼容模式参数
                $pathinfo = $_GET[$this->config['var_pathinfo']];
                unset($_GET[$this->config['var_pathinfo']]);
            } elseif ($this->isCli()) {
                // CLI模式下 index.php module/controller/action/params/...
                $pathinfo = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : '';
            } elseif ('cli-server' == PHP_SAPI) {
                $pathinfo = strpos($this->server('REQUEST_URI'), '?') ? strstr($this->server('REQUEST_URI'), '?', true) : $this->server('REQUEST_URI');
            } elseif ($this->server('PATH_INFO')) {
                $pathinfo = $this->server('PATH_INFO');
            }
 
            // 分析PATHINFO信息
            if (!isset($pathinfo)) {
                foreach ($this->config['pathinfo_fetch'] as $type) {
                    if ($this->server($type)) {
                        $pathinfo = (0 === strpos($this->server($type), $this->server('SCRIPT_NAME'))) ?
                        substr($this->server($type), strlen($this->server('SCRIPT_NAME'))) : $this->server($type);
                        break;
                    }
                }
            }
 
            $this->pathinfo = empty($pathinfo) || '/' == $pathinfo ? '' : ltrim($pathinfo, '/');
        }
 
        return $this->pathinfo;
    }


该路由函数中$this->config['var_pathinfo']是配置文件的默认值,, ,,,,,,其初始化代码如下,, ,,,,,,值为’s’:


8827太阳集团(Macau)股份有限公司-Official website



当要求报文蕴含$_GET['s'],, ,,,,,,就取其值作为pathinfo,, ,,,,,,并返回pathinfo给挪用函数。。。。。。

分析发现pathinfo函数被library/think/Request.php:716中的path函数挪用:

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

 

显然,, ,,,,,,这里$this->path源自pathinfo,, ,,,,,,因而能够被攻击者节造。。。。。。持续分析该变量的传递,, ,,,,,,在library/think/App.php:597中被引用:

//public function routecheck()
$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;


这里是进行路由检测,, ,,,,,,攻击者可控的$path被传递给了如下的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,
        ]);
    }


分析代码可知,, ,,,,,,若是开启了强造路由则会抛出异常,, ,,,,,,也就是官方所说的该缝隙在开启强造路由的情况下不受影响(默认不开启)。。。。。。


Check函数最后事俘化一个UrlDispatch对象,, ,,,,,,将$url传递给了机关函数。。。。。。持续分析UrlDispatch的父类也就是Dispatch类的机关函数:

library/think/route/Dispatch.php:64


 public function __construct(Request $request, Rule $rule, $dispatch, $param = [], $code = null)
    {
        $this->request  = $request;
        $this->rule     = $rule;
        $this->app      = Container::get('app');
        $this->dispatch = $dispatch;
        $this->param    = $param;
        $this->code     = $code;
 
        if (isset($param['convert'])) {
            $this->convert = $param['convert'];
        }
    }


$dispatch变量可控并赋值给了$this->dispatch,, ,,,,,,经过屡次函数挪用返回,, ,,,,,,最后如下的Url类的init 函数将会被挪用来处置$this->dispatch。。。。。。

class Url extends Dispatch
{
    public function init()
    {
        // 解析默认的URL规定
        $result = $this->parseUrl($this->dispatch);
 
        return (new Module($this->request, $this->rule, $result))->init();
    }
 
    public function exec()
    {}


这里挪用parseUrl对$this->dispatch进行解析,, ,,,,,,这是该缝隙的主题点之一:

protected function parseUrl($url)
{
    $depr = $this->rule->getConfig('pathinfo_depr');
    $bind = $this->rule->getRouter()->getBind();
 
    if (!empty($bind) && preg_match('/^[a-z]/is', $bind)) {
        $bind = str_replace('/', $depr, $bind);
        // 若是有� ?? ??????�/节造器绑定
        $url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr);
    }
 
    list($path, $var) = $this->rule->parseUrlPath($url);
    if (empty($path)) {


这里挪用parseUrlPath函数对$url进行解析,, ,,,,,,持续分析该函数:


public function parseUrlPath($url)

    {
       ....
    ....
        } 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的体式为“� ?? ??????�/节造器/操作”,, ,,,,,,$url宰割形成一个数组存到$path变量中并返回到挪用者。。。。。。


持续分析封装路由的代码:


library/think/route/dispatch/Url.php:48

 list($path, $var) = $this->rule->parseUrlPath($url);
    ...
    ...
        // 解析� ?? ??????�
        $module = $this->rule->getConfig('app_multi_module') ? array_shift($path) : null;
 
        if ($this->param['auto_search']) {
            $controller = $this->autoFindController($module, $path);
        } else {
            // 解析节造器
            $controller = !empty($path) ? array_shift($path) : null;
        }
 
        // 解析操作
        $action = !empty($path) ? array_shift($path) : null;
        ...
        ...
        // 设置当前要求的参数
        $this->request->setRouteVars($var);
 
        // 封装路由
        $route = [$module, $controller, $action];
        return $route;


路由封装返回到library/think/route/dispatch/Url.php:20


class Url extends Dispatch

{
    public function init()
    {
        // 解析默认的URL规定
        $result = $this->parseUrl($this->dispatch);
 
        return (new Module($this->request, $this->rule, $result))->init();
    }


$result就是封装好的路由数组,, ,,,,,,传递给了Module的机关函数。。。。。。
由于Module也是继承自Dispatch类,, ,,,,,,直接看Dispatch的机关函数:


public function __construct(Request $request, Rule $rule, $dispatch, $param = [], $code = null)
    {
        $this->request  = $request;
        $this->rule     = $rule;
        $this->app      = Container::get('app');
        $this->dispatch = $dispatch;
        $this->param    = $param;
        $this->code     = $code;
 
        if (isset($param['convert'])) {
            $this->convert = $param['convert'];
        }
    }


$result赋值给了$this->dispatch。。。。。。而后挪用Module类的init函数:


public function init()
    {
        parent::init();
        $result = $this->dispatch;
  
        if ($this->rule->getConfig('app_multi_module')) {
            // 多� ?? ??????椴渴�
            $module    = strip_tags(strtolower($result[0] ?: $this->rule->getConfig('default_module')));
            ...
            ...
            } elseif (!in_array($module, $this->rule->getConfig('deny_module_list')) && is_dir($this->app->getAppPath() . $module)) {
                $available = true;
            } 
           ...
           ...
            // � ?? ??????槌跏蓟�
            if ($module && $available) {
                // 初始化� ?? ??????�
                $this->request->setModule($module);
                $this->app->init($module);
            } else {
                throw new HttpException(404, 'module not exists:' . $module);
            }
        }
        // 获取节造器名
        $controller       = strip_tags($result[1] ?: $this->rule->getConfig('default_controller'));
        $this->controller = $convert ? strtolower($controller) : $controller;
        // 获取操作名
        $this->actionName = strip_tags($result[2] ?: $this->rule->getConfig('default_action'));
        // 设置当前要求的节造器、操作
        $this->request
            ->setController(Loader::parseName($this->controller, 1))
            ->setAction($this->actionName);
 
        return $this;
    }


这里存在第一个对$module的判断,, ,,,,,,必要让$available蹬宗true,, ,,,,,,这就必要is_dir($this->app->getAppPath() . $module)成立。。。。。。官方demo给出的� ?? ??????槭莍ndex,, ,,,,,,而现实开发法式不愿定存在该� ?? ??????槊�,, ,,,,,,所以机关payload使剽里是一个把稳点。。。。。。


满足这个判断前提后,, ,,,,,,持续分析后续的节造流会进入如下module的exec函数:library/think/route/dispatch/Module.php:80


public function exec()

    {
        // 监听module_init
        $this->app['hook']->listen('module_init');
 
        try {
            // 事俘化节造器
            $instance = $this->app->controller($this->controller,
                $this->rule->getConfig('url_controller_layer'),
                $this->rule->getConfig('controller_suffix'),
                $this->rule->getConfig('empty_controller'));
 
            if ($instance instanceof Controller) {
                $instance->registerMiddleware();
            }
        } catch (ClassNotFoundException $e) {
            throw new HttpException(404, 'controller not exists:' . $e->getClass());
        }


分析发现,, ,,,,,,$this->controller是攻击者可控的,, ,,,,,,并传递给了如下的controller函数,, ,,,,,,持续分析该函数:


  public function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
    {
        list($module, $class) = $this->parseModuleAndClass($name, $layer, $appendSuffix);
 
        if (class_exists($class)) {
            return $this->__get($class);
        } elseif ($empty && class_exists($emptyClass = $this->parseClass($module, $layer, $empty, $appendSuffix))) {
            return $this->__get($emptyClass);
        }
        throw new ClassNotFoundException('class not exists:' . $class, $class);
    }


在这里,, ,,,,,,name是攻击者可控的,, ,,,,,,并传递给了如下的parseModuleAndClass函数:


protected function parseModuleAndClass($name, $layer, $appendSuffix)

    {
        if (false !== strpos($name, '\\')) {
            $class  = $name;
            $module = $this->request->module();
        } else {
            if (strpos($name, '/')) {
                list($module, $name) = explode('/', $name, 2);
            } else {
                $module = $this->request->module();
            }
 
            $class = $this->parseClass($module, $layer, $name, $appendSuffix);
        }
 
        return [$module, $class];
    }


分析发现,, ,,,,,,当$name存在反斜杠时就直接将$name赋值给$class并返回。。。。。。显然,, ,,,,,,攻击者通过节造输入就能够操控类的事俘化过程,, ,,,,,,从而造成代码执行缝隙。。。。。。



补丁分析

在ThinkPHP5.0.23以及5.1.31版本中,, ,,,,,,增长了对$controller的正则过滤:


导致无法再传入\think\app这种大局的节造器。。。。。。


结 论

此缝隙是由于框架对传入的路由参数过滤不严格,, ,,,,,,导致攻击者能够操作非预期的节造器类来远程执行代码。。。。。。进一步分析发现,, ,,,,,,某些ThinkPHP版本不受已公开的POC的影响,, ,,,,,,这是由于该POC不足完整性思考。。。。。。因而,, ,,,,,,强烈建议用户实时将5.0.x版本升级到5.0.23,, ,,,,,,将5.1.x版本升级到5.1.31,, ,,,,,,以免遭逢攻击。。。。。。