MoeCTF2024·pop_moe

0x00 前言

新生赛的一道题,在反序列化里算是比较简单的,有必要专门拿来写博客吗?

有。其一,这道题能锻炼我的逻辑,写下来更有利于我的思考和逻辑的培养;其二,反思不看重题的难度,更看重自己学到什么。

本篇文章我会说得比较详细,请读者细细品味,多加思考。


0x01 题目

题目给了四个类:

class000

image-20240816093949866

class001

image-20240816094035000

class002

image-20240816094104671

class003

image-20240816094131537

然后是主程序:

image-20240816094223873

写前须知:

  • 了解:__invoke()__set()等魔术方法
  • 了解PHP反序列化

0x02 分析

我们的目的是什么?我们要想办法得到flag。因为本题没有直接提示flag在哪,所以我们先尝试getshell。我们要怎么实现getshellgetshell的方式有很多,在本题我们看到了一个危险函数eval()和似乎可控的变量,我们需要利用这段程序实现getshell。那怎么利用eval()和似乎可控的变量实现getshell呢,这需要下文的反序列化构造POP链来实现。

概念和宏析

先解释一些概念(我自己编的概念,方便逻辑推理):

起点&终点

  • 起点:反序列化实例化对象后自动触发的点为起点,会引发程序按照特意的目的进行,起点一般是某些魔术方法。
  • 终点:可以直接实现getshell的点。终点一般是危险函数。

最里层&最外层

  • 对象嵌套:举例说明:A类实例的一个属性是B类实例,B类实例的一个属性是C类实例······,以此类推。

  • 最里层:顾名思义,就是对象嵌套里的最里层的那个对象。

  • 最外层:对象嵌套里的最外层的那个对象

宏观分析反序列化:

当我们把序列化的数据交给相关程序时,反序列化函数会生成一个所属类的实例。如果只是一个简单的实例,那么程序就像死水一样毫无动静,但是如果这个实例有魔术方法,当类的实例满足特定的条件时程序就会自动调用这些方法,那么整个程序就动起来了。只有程序动起来才能实现我们的目的。

我们需要一个起始魔术方法也就是上文所说的起点,这个魔术方法所需的条件是必定会满足的,这样程序就可以在反序列化生成实例后自动运行。你能在本题找到符合的魔术方法吗?

image-20240816105153266

没错,本题的起点就是class000的析构函数__destruct()。起点不一定在最外层对象,但是起着运行特定程序的作用,所以写反序列化,我们必须要找到这样一个起点。

我们同样需要一个终点,在终点我们可以直接实现getshell。这样的终点往往是具有危险函数的点,比如本题的:

image-20240816110350102

现在我们知道了触发程序的起点(不一定在最外层的对象),和可以getshell的终点(一般在最里层的对象)。

方法和原则

反序列化的一般方法即是从最里层推到最外层。何为最里层,就是直接拥有危险函数的类的实例。对于本题而言,就是class003

class003eval()是危险函数,我们希望$str是可控变量。在反序列化里,所有属性都是可控变量

要使用eval()需要先使用evvval(),这同样是反序列化的一个重要特征:所有方法的触发都是有条件的

现在我们理清楚了反序列化程序的两个基本元素:

  • 所有属性都是可控变量
  • 所有方法的触发都是有条件的

那么我们就从最里层class003,依照上述原则一层一层往外推。最后我再补充一点,对于各个方法的参数我们通常不主动关注,随着方法的回推参数自然会一层层回推。

不断的回推

我们要使用class003eval(),那么首先要调用class003evvval()。哪里可以调用class003evvval()呢?

把目光转向class002

image-20240816094104671

class002dangerous()方法可以调用evvval()。因为evvval()class003的方法,所以$shaattt必须是class003的实例。那么现在的问题是:我们该怎么调用class002dangerous()呢?

再细看一下dangerous(),这么一个方法是有参数的,通观所有类,只有class002自己的__set()魔术方法可以满足。看看__set() 魔术方法的触发条件:

在给不可访问的(protected或者private)或者不存在的属性赋值的时候,会被调用__set() 魔术方法

上述条件是很广泛的,可以是存在的属性也可以是不存在的属性。我们不妨都找找(寻找的线索就是赋值操作):

第一处:

image-20240816114918979

看起来很怪,$a更像是一个实例或者方法而不是属性。

第二处:

image-20240816115047251

payload属性是所有类都没有的,符合上面的条件。那么class001$a应该是class002class001$payl0ad应该是字符串dangerous(为什么是字符串,这属于PHP基本功)。要满足上述的赋值操作,需要满足__invoke()魔术方法,看看这个魔术方法的使用条件:

__invoke() 当脚本尝试将对象调用为函数时触发,该对象必须是直接拥有__invoke()魔术方法的对象

这里有个细节,上述的对象必须是直接拥有__invoke()魔术方法的对象,不能是嵌套的外层的对象。

现在想想该怎么触发__invoke()魔术方法呢?我们刚才找到了这一处:

image-20240816114918979

如果class000$whatclass001,那么class000$a就是class001?等等,class000好像没有$a这个属性吧?完蛋了吗,好像推不出来了!你会怎么想呢,不妨先往下看,自己想几分钟。

没错,给class000实例外面再套一层class001实例,那么就相当于class000实例有$a属性了。注意!这里的class001实例和上面提到的需要触发__invoke()魔术方法的class001实例是不同的实例。

最后再给class000$payl0ad属性赋个值,别触发die()即可。

流程和POP

大概的流程,也就是嵌套关系,可以粗糙表示为:

class003 < class002 < class001 < class000 <class0001

附上POP链:

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

class class000 {
private $payl0ad = 2;
protected $what;

public function aaa($a)
{
$this->what = $a;
}
}

class class001 {
public $payl0ad="dangerous";
public $a;

}

class class002 {
private $sec;

public function aaa($a)
{
$this->sec = $a;
}


}

class class003 {
public $mystr="system('env');";

}



$_3 = new class003();


$_2 = new class002();
$_2->aaa($_3);

$_10 = new class001();
$_10->a = $_2;

$_0 = new class000();
$_0->aaa($_10);

$_1 = new class001();
$_1->a = $_0;

echo serialize($_1);
echo PHP_EOL;
echo urlencode(serialize($_1));

0x03 小结

回顾此题时,好像没有那么难。当时因为__invoke()的直系问题使我的思路卡了好久,所幸又认真看了相关的概念解释才得以解决该题目。所以基础非常重要基础是推理的根据,就像数学的公理和推论。