作者:啟明星辰ADLab
公眾號:ADLab

漏洞介紹

2018年12月9日,ThinkPHP團隊發布了一個補丁更新,修復了一處由于路由解析缺陷導致的代碼執行漏洞。該漏洞危害程度非常高,默認環境配置即可導致遠程代碼執行。經過啟明星辰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(),如圖所示:

漏洞分析

以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’:

當請求報文包含$_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分割形成一個數組存到$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給出的模塊是index,而實際開發程序不一定存在該模塊名,所以構造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,以免遭受攻擊。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/760/