PHP Session 反序列化(2):漏洞根因 —— 序列化处理器错位与对象注入

本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。 根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文Payload 对外部站点进行测试,违规使用造成的一切后果由使用者自行负责。

所有"实测"输出来自 PHP 8.5.4 真实运行,代码可复制复现。


一、漏洞一句话总结

当一个 session 文件是 php_serialize 格式(整包数组)写出的,却被 php 格式(按 | 切分)去解析时,解码器会把文件里第一个出现的 |(这个 | 完全可能是攻击者写进字符串数据里的)当作"键值分隔符",于是 | 之后的字节被当成一段独立的序列化数据去反序列化 ------ 只要这里放一个 O:4:"Class":...,就会创建任意对象,触发魔术方法,走入 POP 链。

本质一句话:解析格式不匹配 → 本应是"字符串数据"的字节,被重新解释成"对象代码"。 (和"二次注入 SQL"、"字符串逃逸"是同一类思维:格式边界错位。)


二、逐步拆解:一次真实的攻击

2.1 攻击场景设定

假设目标站点存在两个入口,配置不一致:

  • 入口 A(存数据)serialize_handler = php_serialize。业务代码把用户昵称写进 session:

    复制代码
    // save.php —— 用户可控的 $_POST['name'] 被存进 session
    ini_set('session.serialize_handler', 'php_serialize');
    session_start();
    $_SESSION['name'] = $_POST['name'];   // 危险点:不可信数据进 session
  • 入口 B(读数据) :默认 serialize_handler = php,正常 session_start(),页面里类 Test 可被加载。

攻击者把 name 提交成:xxxx|O:4:"Test":0:{}

先别管 payload 里的细节,看它做了什么:在可控字符串中间塞了一个 | ,后面跟一个合法的对象序列化 O:4:"Test":0:{}

2.2 第一步:入口 A 写入(用 php_serialize)

复制代码
// inj_writer.php
session_save_path('/tmp/sesslab');
session_id('demotest01');
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = 'xxxx|O:4:"Test":0:{}';
session_write_close();
echo file_get_contents('/tmp/sesslab/sess_demotest01');

文件内容:

复制代码
a:1:{s:4:"name";s:20:"xxxx|O:4:"Test":0:{}";}

注意:| 安安静静躺在字符串 s:20:"..." 里面,是数据的一部分。文件整体是一个合法数组序列化。此刻一切正常。

2.3 第二步:入口 B 读取(用 php 解析同一份文件)

复制代码
// inj_reader.php
class Test {
    public function __wakeup(){ echo "[__wakeup] 被调用\n"; }
    public function __destruct(){ echo "[__destruct] 对象销毁\n"; }
}
session_save_path('/tmp/sesslab');
session_id('demotest01');
ini_set('session.serialize_handler', 'php');   // 默认也是 php
session_start();
var_dump($_SESSION);

运行输出(实测):

复制代码
[__wakeup] 被调用
[__destruct] 对象销毁
PHP Warning:  session_start(): Failed to decode session object. Session has been destroyed in .../inj_reader.php on line 10
array(0) {
}

Test 对象被创建了!__wakeup 被调用了!对象随后被销毁,__destruct 也被调用了!虽然最终 $_SESSION 是空的、session 被销毁了,但魔术方法已经执行完毕 ------ 攻击目的达成。

2.4 底层发生了什么

php 解码器解析这段 php_serialize 格式的字节:

复制代码
文件: a:1:{s:4:"name";s:20:"xxxx|O:4:"Test":0:{}";}
                 第一个 '|' 在这里(它本来只是字符串内容)
                        │
                        ▼
键名 = a:1:{s:4:"name";s:20:"xxxx
                                 │
                                 ▼
值   = O:4:"Test":0:{}";}   ← 从'|'后开始,php_var_unserialize 解析出一个 Test 对象!
  • 第一步"找第一个 |":整份文件没有合法分隔符,于是第一个 |(在我们注入的字符串里)被选中;

  • | 前面的垃圾 a:1:{s:4:"name";s:20:"xxxx 被当成键名(无所谓,垃圾而已);

  • | 后面的 O:4:"Test":0:{}php_var_unserialize 成功解析成一个 Test 对象

  • 对象后面残留 ";} 无法解析,整个解码以"失败"告终 → PHP 7+ 销毁 session → 对象被析构(__destruct)。

关键领悟 :解码器不做"整体校验",它是"找到能解析的就先解析"。我们的注入对象在任何报错之前就已经被创建、其魔术方法已经被触发

为什么第一个 | 之后的字节能直接解析成对象?解码器不会先校验一下"键名"吗?

不会。php 解码器对键名没有任何格式要求 ,键名就是"| 前的原始字节",哪怕是一堆乱码也照收。真正被严格解析的是 | 后面的"值":php_var_unserialize 对"值起点是否合法"非常挑剔,但只要开头是 O:4: 这种合法序列化开头,它就会成功解析并停在值结尾。垃圾键名完全不影响对象创建。


三、为什么"同一 handler"没洞,而这里就有了?(对比实验)

上一篇 第二部分4.2 节做过对照组:php 写、php 读,值里同样塞 xxxx|O:4:"Test":0:{},结果什么都没触发

原因对比:

php 写 + php 读(安全) php_serialize 写 + php 读(漏洞)
文件长什么样 name|s:20:"xxxx|O:...{}"; a:1:{s:4:"name";s:20:"xxxx|O:...{}";}
第一个 | 是谁 键名后的合法分隔符 藏在字符串数据里的注入字符
谁被反序列化 合法值 s:20:"..."(字符串) 注入的 O:4:"Test":0:{}(对象)
结果 老老实实还原字符串 创建任意对象

安全与否,取决于"第一个 | 是不是合法的"。php_serialize 写出的文件里没有合法 |,所以第一个出现的 | 必然是可被攻击者左右的。这是这个错位组合"百发百中"的物理原因。
为什么必须是 php_serialize 写、php 读?反过来的 php 写 + php_serialize 读不行吗? php 写出的文件带合法的 |(如 name|s:5:"hello";),而 php_serialize 解码器要求整个文件 是一个合法的数组序列化(带 a:N: 及各长度字段)------name|s:5:"hello"; 根本不是合法数组序列化,会直接整体失败,绝不会出现"把你可控字符串的某段误当对象解析"的情况。所以反向组合只是"解析失败",不是"注入成功"。能注入的关键永远是:被解析的那一方(php)存在"按 | 切分后再反序列化值"的窗口


四、payload 的构造要点

4.1 最小可利用 payload

复制代码
可控内容 = 任意前缀 + '|' + 完整对象序列化字符串
例如:   xxxx|O:4:"Test":0:{}

要求:| 后面的字节必须是一个 php_var_unserialize 能成功解析的完整序列化值(对象/数组/字符串都行,打洞用对象)。只要开头对,解析就能成功,哪怕后面跟着垃圾字节(会停在垃圾前)。

如果 session 里存的不是字符串,而是数字 / 数组 / 布尔呢?

注入靠的是"你控制的字节里出现 | "。整数、布尔序列化进去不含 |,天然安全;真正危险的是字符串 ,以及"由可控字符串拼进序列化文本"的字段(比如数组里某个元素值是字符串)。所以判断"能不能注入",看的不是字段整体类型,而是:在 php_serialize 的序列化文本里,有没有一个由用户输入决定的字符串位置 。有,就能放 | 和对象。

4.2 为什么这里"不用算字符串长度",而普通字符串逃逸要算?

这是 Session 错位注入相比"字符串逃逸"舒服的地方,值得说清楚:

  • 你写进 $_SESSION 的字符串,长度是 PHP 替你算好 的(s:20:"..." 里的 20 是真实长度);

  • 攻击起作用的关键是"解码器把 | 当分隔符"------| 只是这个字符串里的一个普通字符,不破坏任何引号/长度结构;

  • 真正被解析的对象 O:4:"Test":0:{}独立完整的序列化数据,它自身长度自洽,不需要你去凑任何长度字段。

所以 payload 构造就是拼字符串,不需要像字符串逃逸那样精心凑长度。唯一要保证的是:| 后紧跟合法的序列化对象,且对象序列化本身 100% 正确。

4.3 构造对象序列化的实用方法

不要手写对象序列化(容易错),用代码生成:

复制代码
class Test { public $cmd; public $x; }
$o = new Test();
$o->cmd = 'id';
echo serialize($o);
// O:4:"Test":2:{s:3:"cmd";s:2:"id";s:1:"x";N;}

拿到这段字符串,前面拼 |(甚至可以再拼点前缀垃圾),作为"可控内容"提交到注入点即可。

如果利用 POP 链(多对象、多属性、含 private/protected 属性),serialize() 输出里属性名会带 \0 空字节和类名前缀 (如 \0Test\0prop)。通过 HTTP 提交时别把空字节搞丢,建议 base64 传输后 PHP 端解码再入库;若注入点本身是 POST 原始值,注意不要被框架二次转义。


五、利用成立的"四要素"(判断题模板,CTF 直接套)

  1. 写点 :存在把用户可控内容写进 session的路径。

    • 业务代码:$_SESSION['x'] = $_POST['x']; 之类;

    • 或没有业务写点 → 用 session.upload_progress(见 04 篇)。

  2. 错位 :session 文件由 php_serialize(或非 php 格式)写入,却由 php 处理器解析。

    • 也可能是 php_binary 写、php 读的变体,但注入难度不同;php_serialize→php 是最好打的组合。
  3. 可控字符串里能放 | :注入点不能过滤掉 | 或把它转义掉(若被转义成 \|,注入即失效)。

  4. 可利用类:目标代码里有可加载的类(危险魔术方法 / 可串 POP 链),与普通反序列化一致。

缺 1 或 4,攻击无法完成;缺 2 或 3,无法注入对象。

注入的 O:4:"Test":0:{} 里,类 Test 必须真实存在吗?

必须。若类不存在,PHP 会生成一个 __PHP_Incomplete_Class 占位对象,__wakeup/__destruct 等魔术方法不会被调用,等于白打。所以"目标代码里有可加载、可利用的类/POP 链"是硬前提------这正是反序列化漏洞总强调"需要有可利用的类"的原因。注入前先到目标源码里找类,别先造 payload。


六、触发点与魔术方法:与普通反序列化有什么区别?

没有任何区别,入口换了个壳。unserialize() 触发和用 session 解码触发的魔术方法完全相同:

魔术方法 触发时机 Session 场景下的实际效果
__wakeup() 对象创建后立即 攻击面 A:解码瞬间执行(如上实测)
__destruct() 对象销毁时 攻击面 B:现代 PHP 解码失败→session 销毁→当场触发(如上实测);老版本可能等到脚本结束
__toString() 对象被当字符串 需要把对象放进某个被 echo/拼接的上下文(POP 链跳板)
__call()/__get()/__set() 访问不存在的成员 POP 链跳板

实战中最常用的是 __destruct :因为 Session 注入的典型场景是"解码失败 → session 被销毁 → 对象立刻析构",等于 PHP 替你把 unset($obj) 都做了。所以找 sink 时优先找 __destruct 里带危险操作的类,或能经 __destruct 串起来的 POP 链。


七、版本差异与行为细节

  1. PHP 7.0+ 解码失败的后果session_start()Warning: Failed to decode session object. Session has been destroyed.,已注入的对象随 session 销毁被析构 。好处是 __destruct 稳定触发;代价是对象存活期很短,无法指望对象在"解码之后、析构之前"被业务代码再次使用。

报错说 session 被 destroyed,是不是说明攻击失败了?

不是。攻击目标是"对象被创建并触发魔术方法",它发生在 session 被销毁之前 (上一篇实测输出顺序:[__wakeup][__destruct] 都先于 Warning)。session 最终空不空、报不报错,都不影响已经执行过的魔术方法。

  1. PHP 5.x :解码失败通常只是跳过该条数据,$_SESSION 可能保留已解析部分,对象析构在请求结束时发生。两种版本对 __wakeup 都是"创建即触发"。

  2. 注入产生的脏数据会让 session 每次读取都失败(文件已被污染)。连续多请求利用时,注意每次都得重新"注入一次",或在一次请求内完成所有步骤。

  3. session_start() 前对文件加锁,同 id 并发会排队;payload 是"先写后读"两个请求,务必让写入请求先结束。

  4. 上面实测是在同一进程 里先 php_serialize 写、再 php 读(复用同一个 session id)。真实场景两个动作发生在不同请求,逻辑完全一致------只要磁盘上文件是 php_serialize 格式、读的入口是 php 即可。


八、把"对象注入"升级成"代码执行"的一般步骤

  1. 审计目标源码,找出所有可被序列化的类 ,定位危险点(system/eval/file_put_contents/include/反序列化嵌套调用...);

  2. 用你的反序列化功底把类串成 POP 链 ,目标:某对象 __destruct(或经跳板)最终调用危险函数;

  3. 用 4.3 的方法 serialize() 出整条链的字符串;

  4. 处理可见性属性(public/protected/private 前缀 \0)与不可序列化属性;

  5. 拼成 |链字符串 提交到写点;

  6. 让目标以 php 处理器访问含该 session 的页面 → 触发。

练手时,把 sink 定为 __destruct 里 echo 一句话 / 写文件,就能直观验证。

相关推荐
秦老师Q1 小时前
php零基础入门到实战,从语法到框架,一篇搞定
开发语言·php
天空之城--1 小时前
Android行业一周动态:编码趋势与行业资讯汇总
android·性能优化·架构·kotlin·android jetpack
后台模板学习3 小时前
2026最新面试真题:到达用英语怎么说背后的逻辑与源码剖析
面试·职场和发展·php
Co_Hui3 小时前
Android Handler 内存泄漏分析
android
张小姐的猫3 小时前
【AI大模型接入SDK】 —— Gemini接入封装
android·数据结构·数据库·c++·人工智能·python
少陽君5 小时前
Python 并发编程:IO 等待用线程/asyncio,CPU 计算才用进程
数据库·python·php
Anhty7 小时前
叮咚变声器上手实测,iOS短板、安卓悬浮窗使用感受分享
android·人工智能·功能测试·ios·智能手机
盛世宏博智慧档案7 小时前
高速 ETC 门架外场机房温湿度远程监测解决方案
服务器·数据库·php·高度·高速·温湿度
方白羽7 小时前
OkHttp 5.3 隐形变更引发的线上偶发崩溃复盘
android·app·客户端