什么是反序列化操作?-类型转换
序列化:对象转换为数组或字符串等格式
反序列化:将数组或字符串等格式转换为对象
serialize() //将对象转换成一个字符串 |
|
unserialize() //将字符串还原成一个对象 |
|
|
|
<?php |
|
// 序列化:将对象/数组 → 字符串(便于存储/传输) |
|
$data = array("name" => "admin", "role" => "user"); |
|
$serialized = serialize($data); |
|
echo $serialized; |
|
// 输出: a:2:{s:4:"name";s:5:"admin";s:4:"role";s:4:"user";} |
|
|
|
// 反序列化:将字符串 → 对象/数组 |
|
$unserialized = unserialize($serialized); |
|
print_r($unserialized); |
序列化格式解读:

a:2:{s:4:"name";s:5:"admin";s:4:"role";s:4:"user";} |
|
│ │ │ │ │ │ │ │ |
|
│ │ │ │ │ │ │ └── 值内容 |
|
│ │ │ │ │ │ └── 值长度 |
|
│ │ │ │ │ └── s = string类型 |
|
│ │ │ │ └── 键内容 |
|
│ │ │ └── 键长度 |
|
│ │ └── s = string类型 |
|
│ └── 2个元素 |
|
└── a = array类型 |
类型标识符
s:5:"hello" → 字符串,长度5 |
|
i:123 → 整数 |
|
b:1 → 布尔值 (1=true, 0=false) |
|
N; → NULL |
|
a:2:{...} → 数组,2个元素 |
|
O:4:"User":2:{...} → 对象,类名长度4,2个属性 |
魔术方法
__construct(): 构造函数,在实例化一个对象的时候,首先会去自动执行的一个方法; |
|
__destruct(): 析构函数,在对象的所有引用被删除或者当对象被显式销毁时执行的魔术方法。 |
|
__toString(): echo或者print只能调用字符串的方式去调用对象,即把对象当成字符串使用,此时自动触发toString() |
|
__invoke(): 把test对象当成函数test()来调用,此时触发invoke() |
|
__call($method, $arguments): 调用的不存在的方法的名称和参数 |
|
__sleep():在调用 serialize() 函数序列化对象之前被调用 |
|
__wakeup():调用unserialize()反序列化函数时反序列化之后调用 |
|
__get($property): 调用的成员属性不存在,用于获取对象的属性值。 |
|
__set($property, $value): 给不存在的成员属性赋值。用于设置对象的属性值。 |
|
__isset($property): 对不可访问属性使用 isset() 或 empty() 时,__isset() 会被调用。用于检测属性是否被设置。 |
|
__unset($property): 在使用 unset() 删除一个不可访问属性时自动调用的方法。用于删除对象的属性。 |
|
__callStatic($method, $arguments): 静态调用或调用成员常量时使用的方法不存在。用于处理静态方法的调用。 |
常见的魔术方法:
__construct():对象被创建时触发(反序列化时通常不触发,但在生成Payload脚本时会用到)。 |
|
__destruct():对象被销毁时触发(脚本执行结束或对象不再被引用时)。这是最常见的反序列化入口点。 |
|
__wakeup():执行 unserialize() 之前触发。 |
|
__sleep():执行 serialize() 之前触发。 |
|
__toString():把对象当成字符串使用时触发(例如 echo $obj;)。 |
|
__invoke():把对象当成函数调用时触发(例如 $obj();)。 |
|
__call():调用不存在的方法时触发。 |
|
__get() / __set():读取或设置不存在/不可访问的属性时触发。 |
CTF中常见坑点与技巧
1、对象变量属性:
public(公共的):在本类内部、外部类、子类都可以访问
protect(受保护的):只有本类或子类或父类中可以访问
private(私人的):只有本类内部可以使用
2、序列化数据显示:
1. 属性的可见性(Public / Private / Protected)
这是最容易踩的坑。
- Public: 序列化出来就是变量名。
- Private: 序列化出来是
\00类名\00变量名(\00是空字节 ASCII 0)。 - Protected: 序列化出来是
\00*\00变量名。
解决办法:
- 在写 EXP 脚本时,直接把属性都改成
public通常也能反序列化成功(PHP特性)。 - 或者在生成 Payload 后,手动把空字符
%00进行 URL 编码。
2. 字符串逃逸
PHP 在反序列化时,底层解析是严格按照“字符串长度”来读取的。如果替换操作导致字符串的实际长度发生了变化,但序列化字符串头部记录的长度数值没有同步变化,就会破坏原本的结构。
这时候,我们可以利用这个长度差,把我们构造的恶意 Payload “挤”出来,或者把后面的结构“吃”进去,从而改变对象的属性。
<?php |
|
// 反序列化引擎按照长度读取字符串,读完就停! |
|
$str = 'a:2:{s:4:"name";s:5:"admin";s:3:"age";s:2:"18";}'; |
|
// ↑ 读5个字符 |
|
|
|
// 即使后面有垃圾数据也能正常解析 |
|
$str = 'a:2:{s:4:"name";s:5:"admin";s:3:"age";s:2:"18";}xxxxx垃圾数据'; |
|
var_dump(unserialize($str)); // 正常解析! |
情况一:关键词变长(增多)—— 把 Payload “挤”出来
假设代码中有一个过滤功能,把所有的字符 'x' 替换成 'yy'(1个字符变成2个)
//例题1 |
|
function filter($str){ |
|
return str_replace('x', 'yy', $str); |
|
} |
|
// 原始类 |
|
class A { |
|
public $name = 'user'; |
|
public $pass = '123456'; |
|
} |
目标:我想把 $pass 修改为 "hacker"。
逻辑推演:
- 正常序列化结果(假设 name 输入 user):
...s:4:"name";s:4:"user";s:4:"pass";s:6:"123456";} - PHP 解析时,读到
s:4:"user",它就乖乖往后读4个字符。 - 攻击思路:如果我输入很多
x,经过filter后,字符串变长了。原本记录的长度(比如10)不够用了,多出来的字符就会被当作后面的代码来解析。
操作步骤:
我们需要构造一个 Payload,使得它正好被“挤”到 $name 值的外面,成为一个新的属性定义。
我们想要的最终结构(Payload):
";s:4:"pass";s:6:"hacker";}
这段字符串长度是 27。
计算数学题:
- 我们要逃逸出的字符长度是 27。
- 每个
'x'变成'yy',长度增加 1。 - 所以我们需要输入 27 个
'x'。
攻击 Payload(输入给 name):
xxxxxxxxxxxxxxxxxxxxxxxxxxx";s:4:"pass";s:6:"hacker";}
发生过程:
- 序列化:PHP 记录 name 长度为 27 + 27 = 54。
...s:54:"xxxxxxxxxxxxxxxxxxxxxxxxxxx";s:4:"pass";s:6:"hacker";}";... - 过滤(变长):27个 x 变成了 54个 y。实际内容变成了 54个y + 我们的Payload。
...s:54:"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";s:4:"pass";s:6:"hacker";}";... - 反序列化:
- PHP 读到
s:54。 - 它往后数 54 个字符,正好读完所有的
y。 - 此时,虽然字符串没结束,但 PHP 认为
$name的值已经读取完毕了! - 关键点:紧接着的
";s:4:"pass";...本来是字符串内容,现在被 PHP 当作了代码结构解析! - 于是,
$pass被成功覆盖为hacker。后面的原始数据会被忽略或丢弃。
- PHP 读到
图解:
a:2:{s:4:"name";s:5:"admin";s:3:"age";s:2:"18";}xxxxx垃圾数据 |
|
│ │ |
|
│←──────────── 完整的序列化结构 ──────────────→│←── 忽略 ──→│ |
|
│ │ |
|
└── 解析到这个 } 就结束了,后面的全部丢弃 ──────┘ |
PHP读到 } 后就结束了解析,之后不管后面是什么,都会忽略。这就是 PHP “读完就停,后面不管” 的特性。
a:2:{s:4:"name";s:5:"admin";s:3:"age";s:2:"18";}xxxxx垃圾数据 |
|
↓ |
|
a:2:{ ... } → 这是一个数组,有2个元素,开始解析大括号内容 |
|
s:4:"name" → 第1个键:字符串,长度4,读取"name" |
|
s:5:"admin" → 第1个值:字符串,长度5,读取"admin" |
|
|
|
s:3:"age" → 第2个键:字符串,长度3,读取"age" |
|
s:2:"18" → 第2个值:字符串,长度2,读取"18" |
|
} → 数组结束,解析完成! |
|
xxxxx垃圾数据 → 直接忽略,不处理,不执行,不报错 |
而针对上面的例题1,我们通过输入很多 x 变成 yy,把字符串挤长了。
攻击后的字符串看起来是这样的:
...s:54:"yyyy...yyyy";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";} |
请注意看我用粗体标出的部分:
s:54:"yyyy...yyyy":这是被挤出来的$name。;s:4:"pass";s:6:"hacker";:这是我们构造的 Payload,成功注入进去了。}:这是我们构造的闭合括号!- PHP 读到这个括号,认为“对象解析完毕了!”
;s:4:"pass";s:6:"123456";}:这是原本正常的代码,变成了垃圾数据。
正是因为 PHP “读完就停,后面不管” 的特性,我们构造的 Payload 里那个提前闭合的 } 才能生效,从而把原本合法的代码(比如原来的密码字段)变成了被遗弃的“垃圾数据”。
反序列化链项目 PHPGGC&NotSoSecure
https://github.com/NotSoSecure/SerializedPayloadGenerator
这个是根据框架来生成的,不是原生。(python是原生。)