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

目录

Level 4:序列化初体验 — 用 serialize() 读取私有属性
4.1 源码回顾
4.2 逐行解释
FLAG3 类
FLAG 类
对象实例化
接收用户输入
4.3 为什么这道题必须用序列化?
回顾前几关的解法,看看为什么都失效了:
序列化的神奇之处
4.4 解题 Payload
执行流程
预期输出示例
输出字符串拆解
4.5 关于 private 属性在序列化中的表示
4.6 空字节导致的显示截断问题
解决方法:URL 编码或 Base64 编码
4.7 为什么不能直接 echo unserialize(serialize($flagishere))?
4.8 手动修改序列化字符串再反序列化的要点
Level 5:序列化规则实战 — 构造正确的序列化数据
5.1 源码回顾
源码给出的"答案"代码
逐行解释
1. 类定义
2. 创建示例变量
序列化结果逐条解释
1. 对象 $a_object
2. 数组 $a_array
3. 字符串 $a_string
4. 整数 $a_number
5. 布尔 $a_boolean
6. null $a_null
完整序列化规则速查表
5.2 核心区别:Level 4 有 eval,Level 5 没有 eval
5.3 为什么参数名是 o=, a=, s= 等?
5.4 PHP 序列化规则速查
5.5 逐项构造正确的序列化值
5.5.1 布尔值 $your_boolean 为 true
5.5.2 $your_NULL 为 null
5.5.3 字符串 $your_string 为 "IWANT"
5.5.4 整数 $your_number 为 1
5.5.5 对象 $yourobject,其 avalue 属性为 "FLAG"
5.5.6 数组 $yourarray,要求 'a' => "Plz", 'b' => "GiveM3"
5.6 正确的 POST 参数
5.7 为什么用 & 分隔参数?
5.8 为什么序列化字符串末尾没有分号?
5.9 为什么 s=s:5:"IWANT";& 中带了分号?
5.10 常见错误提醒
Level 6:序列化的权限修饰规则
6.1 源码回顾
6.2 逐行解释
protectedKEY 类
privateKEY 类
接收用户输入与判断
6.3 为什么 Level 5 的方法不够用了?
6.4 protected 和 private 的序列化格式详解
protected 属性
private 属性
6.5 不可见字符的问题
方法一:URL 编码(推荐)
方法二:本地构造后复制
6.6 解题 Payload
本地构造 EXP
输出结果
直接提交(未编码形式,仅作理解用)
6.7 常见错误提醒
6.8 与 Level 5 的对比
6.9 核心知识点总结
核心概念对比:eval 执行 vs 反序列化输入
序列化格式大全
私有属性处理
private 属性
protected 属性
常见错误与排查
Level 4-5 通关对比总结
常用 Payload 备忘
Level 4
Level 5

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


Level 4:序列化初体验 — 用 serialize() 读取私有属性

4.1 源码回顾

php
<?php class FLAG3{ private $flag3_object_array = array("?","?"); } class FLAG{ private $flag1_string = "?"; private $flag2_number = '?'; private $flag3_object; function __construct() { $this->flag3_object = new FLAG3(); } } $flag_is_here = new FLAG(); $code = $_POST['code']; if(isset($code)){ eval($code); } else { highlight_file(__FILE__); }

4.2 逐行解释

FLAG3 类

php
class FLAG3{ private $flag3_object_array = array("?","?"); }
代码解释
class FLAG3{ ... }定义一个名为 FLAG3 的类
private $flag3_object_array声明一个私有属性,名字叫 $flag3_object_array
= array("?","?")默认值是一个数组,里面有两个元素(问号是占位符,实际部署时是真实 flag 片段)

关键点:

  • 这个属性是 private(私有)的
  • 类里面没有任何公共方法(没有 getter)
  • 这意味着:从类外部直接访问不了,也没有方法可以间接获取

回想 Level 3:FLAG 类里有 get_protected_flag() 和 get_private_flag() 两个公共方法,我们可以通过调用方法来间接读取私有属性。但 FLAG3 类里一个方法都没有,所以没法走"公共方法间接获取"这条路。

FLAG 类

php
class FLAG{ private $flag1_string = "?"; private $flag2_number = '?'; private $flag3_object; function __construct() { $this->flag3_object = new FLAG3(); } }
代码解释
class FLAG{ ... }定义主类 FLAG
private $flag1_string = "?"私有属性,字符串类型,存 flag 第一部分
private $flag2_number = '?'私有属性,这里用了单引号,存 flag 第二部分(可能是数字或其他)
private $flag3_object私有属性,初始没有赋值,类型是对象
function __construct() { ... }构造函数,new FLAG() 时自动执行
$this->flag3_object = new FLAG3();构造函数里创建了一个 FLAG3 对象,赋值给 $flag3_object

关键点:

  • FLAG 类的三个属性全是 private
  • FLAG 类里也没有任何公共方法(没有 getter)
  • 构造函数只是给 $flag3_object 赋了一个 FLAG3 对象,没有输出任何东西

所以这道题既不能像 Level 1 那样靠构造函数输出,也不能像 Level 3 那样靠公共方法获取。那怎么办?HINT 已经告诉你了:试试序列化!

对象实例化

php
$flag_is_here = new FLAG();
代码解释
$flag_is_here变量名,意思是"flag 就在这里"
new FLAG()实例化 FLAG 类,触发构造函数

执行后 $flag_is_here 对象的结构:

$flag_is_here (FLAG 对象) ├── private $flag1_string = "?" ← flag 第一部分 ├── private $flag2_number = '?' ← flag 第二部分 └── private $flag3_object = FLAG3 对象 ← flag 第三部分在 FLAG3 对象里 └── private $flag3_object_array = ["?", "?"] ← flag 第三部分

flag 被嵌套存储:FLAG 对象 → FLAG3 对象 → 数组 → 数组元素

接收用户输入

php
$code = $_POST['code']; if(isset($code)){ eval($code); } else { highlight_file(__FILE__); }
代码解释
$code = $_POST['code'];从 POST 请求获取 code 参数
if(isset($code))判断有没有提交 code
eval($code);执行用户提交的 PHP 代码(任意代码执行点)
highlight_file(__FILE__);没提交 code 时,显示当前文件的源码

4.3 为什么这道题必须用序列化?

回顾前几关的解法,看看为什么都失效了:

关卡解法本题为什么不行
Level 1new FLAG() 触发构造函数输出本题构造函数里只有赋值,没有 echo
Level 2$target->free_flag = $flag_string本题没有外部全局变量 $flag_string
Level 3$target->get_private_flag()本题 FLAG 和 FLAG3 类里没有任何公共方法

序列化的神奇之处

serialize() 函数可以把对象的所有属性(包括 private 和 protected)都转换成字符串!

这是 PHP 序列化的一个特性:

序列化不关心权限修饰符,它会忠实地把对象的所有属性名和属性值都编码进字符串里。

也就是说:

  • private 属性平时访问不了 ❌
  • 但 serialize() 可以把 private 属性的值"泄露"出来 ✅

举个栗子吧

php
class Secret { private $hidden = "我是秘密"; public $shown = "我是公开"; } $obj = new Secret(); echo serialize($obj);
O:6:"Secret":2:{s:12:"Secrethidden";s:12:"我是秘密";s:5:"shown";s:12:"我是公开";}

4.4 解题 Payload

php
echo serialize($flag_is_here);

提交:

code=echo+serialize($flag_is_here);

2ed63c608604519e6022af22f9b55741.png

执行流程

  1. eval($code) 执行 echo serialize($flag_is_here);
  2. serialize($flag_is_here) 把 $flag_is_here 对象转成字符串
  3. 字符串里包含了所有属性的值(包括 private 的)
  4. echo 输出这个字符串,你就能看到 flag 了

预期输出示例

php
O:4:"FLAG":3:{s:18:"FLAGflag1_string";s:8:"ser4l1ze";s:18:"FLAGflag2_number";i:2;s:18:"FLAGflag3_object";O:5:"FLAG3":1:{s:25:"FLAG3flag3_object_array";a:2:{i:0;s:3:"se3";i:1;s:2:"me";}}}

输出字符串拆解

php
O:4:"FLAG":3:{ ← FLAG 对象,3个属性 s:18:"FLAGflag1_string";s:8:"ser4l1ze"; ← private $flag1_string 的值 s:18:"FLAGflag2_number";i:2; ← private $flag2_number 的值 s:18:"FLAGflag3_object";O:5:"FLAG3":1:{ ← private $flag3_object 是 FLAG3 对象 s:25:"FLAG3flag3_object_array";a:2:{ ← FLAG3 的 private 属性 i:0;s:3:"se3"; i:1;s:2:"me"; } } }

从输出中提取 flag 片段拼接即可。

4.5 关于 private 属性在序列化中的表示

你可能注意到属性名前面有奇怪的东西:

php
s:18:"FLAGflag1_string"

flag1_string 明明只有 14 个字符,为什么长度是 18?

因为 private 属性在序列化时,会在属性名前加上:

类名 属性名

即:

  • 是 ASCII 值为 0 的空字符(不可见)
  • 类名是 FLAG
  • 所以实际存储的是: FLAG flag1_string

计算长度:

  • = 1 个字符
  • FLAG = 4 个字符
  • = 1 个字符
  • flag1_string = 14 个字符
  • 总计:1 + 4 + 1 + 14 = 18 ✅

同理,FLAG3flag3_object_array 的长度是 25:

  • + FLAG3 + + flag3_object_array = 1 + 5 + 1 + 18 = 25 ✅

这也是为什么在 Level 6 会学到:输出序列化字符串时建议用 urlencode(),避免这些不可见字符在传输过程中丢失。

4.6 空字节导致的显示截断问题

即使使用 echo serialize($flag_is_here),浏览器或工具中看到的响应也可能被截断。例如:

O:4:"FLAG":3:{s:18:"

原因就是 s:18:" 后面跟的是空字节 ,在大多数文本显示环境中,空字节之后的字符会被视为空白或直接截断显示。实际上 TCP 层面的数据是完整的,只是开启美化后看不到。

解决方法:URL 编码或 Base64 编码

如果麻烦一些,可以在输出前对序列化字符串进行编码。

方法一:URL 编码

php
echo urlencode(serialize($flag_is_here));

响应示例(部分):

O%3A4%3A%22FLAG%22%3A3%3A%7Bs%3A18%3A%22%00FLAG%00flag1_string%22%3Bs%3A8%3A...

%00 就是空字节的 URL 编码,解码后即可恢复完整的序列化字符串。

方法二:Base64 编码

php
echo base64_encode(serialize($flag_is_here));

拿到 Base64 后在本地解码,也能得到完整的原始数据。

方法三:var_dump 递归输出

php
var_dump($flag_is_here);

直接输出对象的完整结构,也能看到所有属性值。

4.7 为什么不能直接 echo unserialize(serialize($flag_is_here))?

php
echo unserialize(serialize($flag_is_here));

这段代码会失败,因为:

  1. serialize($flag_is_here) 把对象转成字符串
  2. unserialize(...) 把字符串还原成 FLAG 对象
  3. echo 要求输出的内容能够转换成字符串
  4. 但 FLAG 类没有定义 __toString() 方法
  5. 所以 PHP 会抛出致命错误:Object of class FLAG could not be converted to string

正确的做法是直接输出序列化字符串,而不是再反序列化回去输出对象。

php
echo serialize($flag_is_here); // ✅ 正确

4.8 手动修改序列化字符串再反序列化的要点

若想在拿到序列化数据后修改某些属性值再反序列化(例如用于注入攻击),需要严格遵循私有属性的命名规则:

  • 私有属性名必须保持 类名 属性名 的格式,空字节不能丢
  • 修改属性值后,值的长度声明必须与新的值长度一致
  • 如果修改值导致整体字符串长度变化,只需更新该值前面的长度标记(如 s:8 改为 s:5),其他部分的结构不变

推荐的操作方式:在代码中使用反射直接修改对象的私有属性,然后重新 serialize,这样不会出错。

php
$obj = unserialize($original_str); $ref = new ReflectionClass($obj); $prop = $ref->getProperty('flag1_string'); $prop->setAccessible(true); $prop->setValue($obj, 'new_value'); echo serialize($obj);

Level 5:序列化规则实战 — 构造正确的序列化数据

5.1 源码回顾

php
<?php class a_class{ public $a_value = "HelloCTF"; } $your_object = unserialize($_POST['o']); $your_array = unserialize($_POST['a']); $your_string = unserialize($_POST['s']); $your_number = unserialize($_POST['i']); $your_boolean = unserialize($_POST['b']); $your_NULL = unserialize($_POST['n']); if( $your_boolean && $your_NULL == null && $your_string == "IWANT" && $your_number == 1 && $your_object->a_value == "FLAG" && $your_array['a'] == "Plz" && $your_array['b'] == "Give_M3" ){ echo $flag; }else{ echo "You really know how to serialize?"; }

在这串源码的上方,上来他就给了一大串代码,让我们逐行来分析


源码给出的"答案"代码

php
class a_class{ public $a_value = "HelloCTF"; } $a_object = new a_class(); $a_array = array(a=>"Hello",b=>"CTF"); $a_string = "HelloCTF"; $a_number = 678470; $a_boolean = true; $a_null = null;

逐行解释

1. 类定义

php
class a_class{ public $a_value = "HelloCTF"; }
  • 定义了一个类 a_class,名字长度是 7 个字符(a-_-c-l-a-s-s)
  • 里面有一个 public 属性 $a_value,默认值是 "HelloCTF"
  • "HelloCTF" 的长度是 8 个字符

2. 创建示例变量

php
$a_object = new a_class();
  • 实例化 a_class,创建一个对象
  • 这个对象有一个属性 a_value,值是 "HelloCTF"
php
$a_array = array(a=>"Hello",b=>"CTF");
  • 创建一个关联数组,有两个键值对:
    • 键 "a" → 值 "Hello"
    • 键 "b" → 值 "CTF"
php
$a_string = "HelloCTF";
  • 一个普通字符串变量,值是 "HelloCTF"
php
$a_number = 678470;
  • 一个整数变量,值是 678470
php
$a_boolean = true;
  • 一个布尔变量,值是 true
php
$a_null = null;
  • 一个 null 变量

序列化结果逐条解释

1. 对象 $a_object

O:7:"a_class":1:{s:7:"a_value";s:8:"HelloCTF";}
片段含义
OObject(对象)
:7:类名长度,"a_class" 是 7 个字符
"a_class"类名
:1:这个对象有 1 个属性
{...}属性列表
s:7:"a_value"属性名是字符串,长度 7,内容是 "a_value"
s:8:"HelloCTF"属性值是字符串,长度 8,内容是 "HelloCTF"

注意:因为是 public 属性,所以属性名前面没有 \0 前缀。如果是 private 或 protected,前面会有空字节。


2. 数组 $a_array

a:2:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";}
片段含义
aArray(数组)
:2:数组有 2 个元素
{...}元素列表
s:1:"a"第一个键,字符串,长度 1,内容是 "a"
s:5:"Hello"第一个值,字符串,长度 5,内容是 "Hello"
s:1:"b"第二个键,字符串,长度 1,内容是 "b"
s:3:"CTF"第二个值,字符串,长度 3,内容是 "CTF"

数组的每个元素都是 键;值; 成对出现。


3. 字符串 $a_string

s:8:"HelloCTF";
片段含义
sString(字符串)
:8:字符串长度,"HelloCTF" 是 8 个字符
"HelloCTF"字符串内容

4. 整数 $a_number

i:678470;
片段含义
iInteger(整数)
:678470整数值
;结束符

5. 布尔 $a_boolean

b:1;
片段含义
bBoolean(布尔)
:1true 用 1 表示
;结束符

false 会序列化为 b:0;


6. null $a_null

N;
片段含义
NNull(空)
;结束符

null 最简单,就一个 N;


完整序列化规则速查表

类型标识格式示例
字符串ss:长度:"内容";s:8:"HelloCTF";
整数ii:数值;i:678470;
布尔bb:1; (true) / b:0; (false)b:1;
nullNN;N;
数组aa:个数:{键;值;键;值;...}a:2:{s:1:"a";s:5:"Hello";...}
对象OO:类名长度:"类名":属性数:{...}O:7:"a_class":1:{...}

5.2 核心区别:Level 4 有 eval,Level 5 没有 eval

这是两道题最关键的区别,很多小白在这里会混淆:

对比项Level 4Level 5
代码执行方式eval($code) — 任意代码执行unserialize($_POST['x']) — 反序列化
你需要提交什么PHP 代码(如 echo serialize(...))序列化字符串(如 b:1;)
参数名只有一个 code6 个参数:o, a, s, i, b, n
漏洞类型代码执行反序列化输入校验

Level 5 根本不需要 eval! 代码直接把你 POST 过来的 6 个字符串分别反序列化成变量,然后进行 if 判断。你不需要执行额外的命令,只需要提供正确的序列化数据即可。

5.3 为什么参数名是 o=, a=, s= 等?

因为 PHP 代码明确写了:

php
$your_object = unserialize($_POST['o']); $your_array = unserialize($_POST['a']); $your_string = unserialize($_POST['s']); $your_number = unserialize($_POST['i']); $your_boolean = unserialize($_POST['b']); $your_NULL = unserialize($_POST['n']);

它从 POST 请求中读取名为 o, a, s, i, b, n 的参数。所以你必须用对应的参数名来传递序列化字符串。参数名是题目代码规定的,不能改动。

5.4 PHP 序列化规则速查

类型序列化格式示例
字符串s:长度:"内容";s:5:"Hello";
整数i:数值;i:123;
布尔值b:1; (true) 或 b:0; (false)b:1;
nullN;N;
数组a:元素个数:{键;值;键;值;...}a:2:{s:1:"a";s:3:"Plz";...}
对象O:类名长度:"类名":属性个数:{属性名;值;...}O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}

注意:所有长度必须与字符串的实际字节数严格一致,语句必须以分号分隔。

5.5 逐项构造正确的序列化值

这里就和答案代码大差不差了

5.5.1 布尔值 $your_boolean 为 true

序列化格式:b:1;

5.5.2 $your_NULL 为 null

序列化格式:N;

5.5.3 字符串 $your_string 为 "IWANT"

长度:5(I-W-A-N-T,共5个字符)

序列化格式:s:5:"IWANT";

5.5.4 整数 $your_number 为 1

序列化格式:i:1;

5.5.5 对象 $your_object,其 a_value 属性为 "FLAG"

  • 类名:a_class (长度为 7)
  • 属性个数:1
  • 属性名:a_value (长度为 7,public 属性不需要空字节)
  • 值:"FLAG" (长度为 4)

完整格式:

O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}

⚠️ 常见错误:

  • s:4:"FLAG" 前面缺少属性名定义 —— 必须有 s:7:"a_value"; 来声明属性名

5.5.6 数组 $your_array,要求 'a' => "Plz", 'b' => "Give_M3"

  • 数组元素个数:2
  • 键 'a' 为字符串,长度 1,值 "Plz" 长度 3
  • 键 'b' 为字符串,长度 1,值 "Give_M3" 长度 7

完整格式:

a:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}

5.6 正确的 POST 参数

在 HTTP 请求体中,需要将 6 个参数分别填入正确的序列化字符串:

参数名值(序列化字符串)
oO:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}
aa:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}
ss:5:"IWANT";
ii:1;
bb:1;
nN;

完整 payload(未编码形式,直接用于 yakit):

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;

94ad8c67000d8a04f7a08ae46536ca40.png

5.7 为什么用 & 分隔参数?

& 是 HTTP 查询字符串/表单参数的分隔符,用于连接多个 key=value 对。

  • o=...&a=...&s=... 表示三个不同的 POST 参数
  • && 是 PHP 逻辑与运算符,不能出现在 URL 参数中分隔参数

你在 Burp Suite 中看到的是 application/x-www-form-urlencoded 格式的请求体,多个参数用 & 连接,这是 HTTP 协议层面的规则,与 PHP 的 && 毫无关系。

5.8 为什么序列化字符串末尾没有分号?

PHP 序列化字符串的结尾不加额外的分号。每个值的表示是一个完整的单元,以 } 或值的字面量结束,单元之后不再有分号。

例子:

  • 字符串:s:5:"IWANT"; ← 这个分号属于字符串声明的结束,是序列化格式要求的
  • 对象:O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";} ← 结尾是 },没有分号
  • 数组:a:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";} ← 结尾是 },没有分号

序列化语法里的分号只出现在以下位置:

  • 每个属性/元素的键和值声明之后(如 s:7:"a_value";)
  • 字符串长度声明与内容之间没有分号,内容后的分号标志该字符串值的结束

所以,当你把整个对象的序列化字符串放进参数 o= 时,内部自有分号,但整体字符串末尾没有分号。这就是为什么看到 ...;} 而没在 } 后面加 ;。

5.9 为什么 s=s:5:"IWANT";& 中带了分号?

因为 $_POST['s'] 接收的是字符串 "IWANT" 的序列化结果。

"IWANT" 序列化后就是 s:5:"IWANT";,这个分号是序列化格式本身的一部分,不是你额外加的。

整个参数值就是 s:5:"IWANT";,所以当你写成 s=s:5:"IWANT"; 并放在请求体中时,第一个 s 是参数名,等号后面的 s:5:"IWANT"; 是参数值。值里必须包含分号,否则 PHP 的 unserialize 会解析失败。

5.10 常见错误提醒

错误原因正确写法
s:8:"IWANT"字符串长度算错,"IWANT" 只有 5 个字符s:5:"IWANT"
O:7:"a_value"对象类名写错,错把属性名当成类名O:7:"a_class"
O:7:"a_class":1:{s:4:"FLAG";}缺少属性名定义O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}
缺少数组参数 a只提交了部分参数必须提交 o, a, s, i, b, n 六个参数
用 && 分隔参数混淆了 PHP 逻辑运算符和 HTTP 参数分隔符用 & 分隔

Level 6:序列化的权限修饰规则

6.1 源码回顾

php
<?php class protectedKEY{ protected $protected_key = "???"; } class privateKEY{ private $private_key = "???"; } $pk = unserialize($_POST['protected_key']); $prk = unserialize($_POST['private_key']); if( $pk->protected_key == "protected_key" && $prk->private_key == "private_key" ){ echo $flag; }else{ echo "Check your serialize string!"; }

6.2 逐行解释

protectedKEY 类

php
class protectedKEY{ protected $protected_key = "???"; }
代码解释
class protectedKEY{ ... }定义一个名为 protectedKEY 的类
protected $protected_key声明一个 protected(受保护) 属性
= "???"默认值是占位符,实际部署时是待匹配的值

关键点:

  • protected 属性只能在本类内部和子类内部访问
  • 类外部不能直接读取,也不能直接修改
  • 这道题没有提供 getter 方法,和 Level 3 一样"拿不到"属性值

privateKEY 类

php
class privateKEY{ private $private_key = "???"; }
代码解释
class privateKEY{ ... }定义一个名为 privateKEY 的类
private $private_key声明一个 private(私有) 属性
= "???"默认值是占位符,实际部署时是待匹配的值

关键点:

  • private 属性只能在本类内部访问,子类都继承不了
  • 同样没有 getter 方法,外部完全"看不到"

接收用户输入与判断

php
$pk = unserialize($_POST['protected_key']); $prk = unserialize($_POST['private_key']); if( $pk->protected_key == "protected_key" && $prk->private_key == "private_key" ){ echo $flag; }
代码解释
$pk = unserialize($_POST['protected_key']);从 POST 参数 protected_key 获取序列化字符串,反序列化成 protectedKEY 对象
$prk = unserialize($_POST['private_key']);从 POST 参数 private_key 获取序列化字符串,反序列化成 privateKEY 对象
if(...)判断反序列化后的对象属性值是否等于目标字符串
echo $flag;条件满足则输出 flag

注意:这里的判断是 $pk->protected_key == "protected_key",也就是说,反序列化后对象的属性值必须是 "protected_key"。但源码里默认值是 "???",所以我们必须通过反序列化字符串来"覆盖"这个默认值。

6.3 为什么 Level 5 的方法不够用了?

在 Level 5 中,所有属性都是 public,序列化字符串长这样:

s:7:"a_value";s:4:"FLAG";

属性名干干净净,直接就是 "a_value",没有任何前缀。

但 Level 6 引入了 protected 和 private,事情就变了:

修饰符Level 5 (public)Level 6 (protected/private)
属性名格式s:7:"a_value"s:15:"\0*\0protected_key" 或 s:23:"\0privateKEY\0private_key"
是否有不可见字符❌ 没有✅ 有空字节 \0
直接复制是否可行✅ 可以❌ 空字节会丢失

如果你按照 Level 5 的思路,直接构造:

O:12:"protectedKEY":1:{s:12:"protected_key";s:15:"protected_key";}

这是错的! 因为 protected 属性的序列化名不是 "protected_key",而是 "\0*\0protected_key"。

6.4 protected 和 private 的序列化格式详解

protected 属性

格式:\0*\0属性名

php
class protectedKEY{ protected $protected_key = "protected_key"; } // 序列化后属性名部分:s:15:"\0*\0protected_key"

长度计算:

  • \0 = 1 个字符
  • * = 1 个字符
  • \0 = 1 个字符
  • protected_key = 13 个字符
  • 总计:1 + 1 + 1 + 13 = 15 ✅

完整序列化字符串:

O:12:"protectedKEY":1:{s:15:"\0*\0protected_key";s:15:"protected_key";}

private 属性

格式:\0类名\0属性名

php
class privateKEY{ private $private_key = "private_key"; } // 序列化后属性名部分:s:23:"\0privateKEY\0private_key"

长度计算:

  • \0 = 1 个字符
  • privateKEY = 10 个字符
  • \0 = 1 个字符
  • private_key = 11 个字符
  • 总计:1 + 10 + 1 + 11 = 23 ✅

完整序列化字符串:

O:10:"privateKEY":1:{s:23:"\0privateKEY\0private_key";s:11:"private_key";}

6.5 不可见字符的问题

\0 是 ASCII 码为 0 的空字符(NULL),在大多数文本环境中:

  • 浏览器里可能显示为空白或截断
  • 复制粘贴时容易丢失
  • 直接打在 URL 里会被截断

所以这道题的核心考点就是:怎么处理这些不可见字符?

方法一:URL 编码(推荐)

\0 的 URL 编码是 %00,肉眼可见且不会丢失。

php
echo urlencode(serialize(new protectedKEY)); // 输出:O%3A12%3A%22protectedKEY%22%3A1%3A%7Bs%3A15%3A%22%00%2A%00protected_key%22%3Bs%3A15%3A%22protected_key%22%3B%7D

注意:

  • %00 就是 \0 的 URL 编码
  • %2A 就是 * 的 URL 编码

方法二:本地构造后复制

在本地 PHP 环境里:

php
<?php class protectedKEY{ protected $protected_key = "protected_key"; } class privateKEY{ private $private_key = "private_key"; } echo urlencode(serialize(new protectedKEY)); echo "\n"; echo urlencode(serialize(new privateKEY));

然后把输出的 URL 编码字符串直接贴到 POST 参数里。

6.6 解题 Payload

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

图片.png

本地构造 EXP

php
<?php class protectedKEY{ protected $protected_key = "protected_key"; } class privateKEY{ private $private_key = "private_key"; } $exp = "protected_key=".urlencode(serialize(new protectedKEY))."&private_key=".urlencode(serialize(new privateKEY)); echo $exp;

输出结果

protected_key=O%3A12%3A%22protectedKEY%22%3A1%3A%7Bs%3A15%3A%22%00%2A%00protected_key%22%3Bs%3A15%3A%22protected_key%22%3B%7D&private_key=O%3A10%3A%22privateKEY%22%3A1%3A%7Bs%3A23%3A%22%00privateKEY%00private_key%22%3Bs%3A11%3A%22private_key%22%3B%7D

直接提交(未编码形式,仅作理解用)

如果你能在请求中精确输入空字节(比如在 Burp Suite 的 Hex 模式下),也可以直接提交:

protected_key=O:12:"protectedKEY":1:{s:15:"\0*\0protected_key";s:15:"protected_key";}&private_key=O:10:"privateKEY":1:{s:23:"\0privateKEY\0private_key";s:11:"private_key";}

⚠️ 注意:上面这行里的 \0 在实际提交时应该是真正的空字节,或者直接用 URL 编码后的版本。在普通文本框里输入 %00 不会被自动解码成空字节,所以推荐用本地 PHP 生成 urlencode 后的字符串。

6.7 常见错误提醒

错误原因正确写法
s:12:"protected_key"忽略了 protected 属性的 \0*\0 前缀s:15:"\0*\0protected_key"
s:11:"private_key"忽略了 private 属性的 \0类名\0 前缀s:23:"\0privateKEY\0private_key"
空字节在传输中丢失直接复制粘贴,浏览器/编辑器吞掉了 \0用 urlencode() 编码后再传输
类名写错private 前缀必须带当前类名,不是属性名\0privateKEY\0private_key ✅
长度算错没把 \0 和类名算进长度重新数:1+10+1+11=23

6.8 与 Level 5 的对比

对比项Level 5Level 6
核心考点各种数据类型的序列化格式protected / private 的序列化特殊格式
属性修饰符全是 publicprotected + private
属性名格式直接写名字,如 "a_value"带前缀:\0*\0名字 或 \0类名\0名字
是否有不可见字符❌ 没有✅ 有空字节 \0
是否需要 urlencode不需要强烈建议用 urlencode
提交方式直接贴序列化字符串本地生成 → urlencode → 粘贴
长度计算复杂度简单,直接数字符需要加上前缀的隐藏字符

6.9 核心知识点总结

修饰符序列化属性名格式长度计算示例
public属性名strlen(属性名)s:7:"a_value"
protected\0*\0属性名3 + strlen(属性名)s:15:"\0*\0protected_key"
private\0类名\0属性名2 + strlen(类名) + strlen(属性名)s:23:"\0privateKEY\0private_key"

核心概念对比:eval 执行 vs 反序列化输入

┌─────────────────────────────────────────────────────────────┐ │ Level 4(eval 执行) │ ├─────────────────────────────────────────────────────────────┤ │ 你提交的:PHP 代码(如 echo serialize($flag_is_here);) │ │ 服务器做的:eval(你的代码) → 执行你的代码 → 输出结果 │ │ 参数名:code │ │ 漏洞类型:任意代码执行 │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Level 5(反序列化输入) │ ├─────────────────────────────────────────────────────────────┤ │ 你提交的:序列化字符串(如 b:1;) │ │ 服务器做的:unserialize(你的字符串) → 还原成变量 → if 判断 │ │ 参数名:o, a, s, i, b, n(6个参数) │ │ 漏洞类型:反序列化输入校验(本题是正向利用,非漏洞) │ └─────────────────────────────────────────────────────────────┘

关键区别:

  • Level 4 中你提交的是代码,服务器用 eval() 执行它
  • Level 5 中你提交的是数据,服务器用 unserialize() 解析它
  • 这是两个完全不同的概念,不要混淆!

序列化格式大全

类型标识含义格式示例
aarray 数组a:数量:{键;值;...}a:2:{i:0;s:3:"tan";i:1;s:2:"ji";}
bboolean 布尔b:1; (true) / b:0; (false)b:1;
Ccustom object 自定义对象C:类名长度:"类名":数据长度:{数据}C:4:"User":125:{...}
ddouble 小数d:数值;d:1.1;
iinteger 整数i:数值;i:114;
Nnull NULLN;N;
OObject 对象O:类名长度:"类名":属性数:{...}O:4:"FLAG":1:{...}
rreference 对象引用r:编号;r:2;
Rpointer reference 指针引用R:编号;R:2;
sstring 字符串s:长度:"内容";s:8:"HelloCTF";
Sencoded string 编码字符串S:长度:"十六进制编码";S:1:"1";

私有属性处理

private 属性

格式:类名 属性名

示例:

php
class Demo{ private $c; } // 序列化后:s:7:" Demo c" // 长度 = 1( ) + 4(Demo) + 1( ) + 1(c) = 7

protected 属性

格式: * 属性名

示例:

php
class Demo{ protected $b; } // 序列化后:s:4:" * b" // 长度 = 1( ) + 1(*) + 1( ) + 1(b) = 4

常见错误与排查

现象可能原因排查方法
unserialize() 报错长度不匹配仔细检查 s:长度 是否与实际字符数一致
Object could not be converted to string直接 echo 对象用 serialize() 或 var_dump()
条件判断不通过序列化数据构造错误逐项检查类型和值是否匹配
参数名错误混淆了 o 和 code看清楚代码里用的是 $_POST['什么']

Level 4-5 通关对比总结

对比项Level 4Level 5
核心考点序列化初体验序列化规则实战
flag 获取方式用 serialize() 读取已有对象的私有属性构造正确的序列化数据通过条件判断
代码执行方式eval($code) — 任意代码执行unserialize($_POST['x']) — 反序列化
提交内容PHP 代码序列化字符串
参数数量1 个 (code)6 个 (o, a, s, i, b, n)
关键函数serialize(), eval()unserialize()
新知识点序列化可以绕过权限修饰符各种数据类型的序列化格式
常见坑空字节显示截断字符串长度算错、对象结构写错

常用 Payload 备忘

Level 4

目的Payload
直接查看序列化(可能截断)code=echo serialize($flag_is_here);
URL 编码后查看code=echo urlencode(serialize($flag_is_here));
Base64 编码后查看code=echo base64_encode(serialize($flag_is_here));
使用 var_dump 递归输出code=var_dump($flag_is_here);

Level 5

参数值
oO:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";}
aa:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}
ss:5:"IWANT";
ii:1;
bb:1;
nN;