本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。 根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文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 直接套)
-
写点 :存在把用户可控内容写进 session的路径。
-
业务代码:
$_SESSION['x'] = $_POST['x'];之类; -
或没有业务写点 → 用
session.upload_progress(见 04 篇)。
-
-
错位 :session 文件由
php_serialize(或非 php 格式)写入,却由php处理器解析。- 也可能是
php_binary写、php读的变体,但注入难度不同;php_serialize→php是最好打的组合。
- 也可能是
-
可控字符串里能放
|:注入点不能过滤掉|或把它转义掉(若被转义成\|,注入即失效)。 -
可利用类:目标代码里有可加载的类(危险魔术方法 / 可串 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 链。
七、版本差异与行为细节
- PHP 7.0+ 解码失败的后果 :
session_start()报Warning: Failed to decode session object. Session has been destroyed.,已注入的对象随 session 销毁被析构 。好处是__destruct稳定触发;代价是对象存活期很短,无法指望对象在"解码之后、析构之前"被业务代码再次使用。
报错说 session 被 destroyed,是不是说明攻击失败了?
不是。攻击目标是"对象被创建并触发魔术方法",它发生在 session 被销毁之前 (上一篇实测输出顺序:
[__wakeup]、[__destruct]都先于 Warning)。session 最终空不空、报不报错,都不影响已经执行过的魔术方法。
-
PHP 5.x :解码失败通常只是跳过该条数据,
$_SESSION可能保留已解析部分,对象析构在请求结束时发生。两种版本对__wakeup都是"创建即触发"。 -
注入产生的脏数据会让 session 每次读取都失败(文件已被污染)。连续多请求利用时,注意每次都得重新"注入一次",或在一次请求内完成所有步骤。
-
session_start()前对文件加锁,同 id 并发会排队;payload 是"先写后读"两个请求,务必让写入请求先结束。 -
上面实测是在同一进程 里先
php_serialize写、再php读(复用同一个 session id)。真实场景两个动作发生在不同请求,逻辑完全一致------只要磁盘上文件是 php_serialize 格式、读的入口是 php 即可。
八、把"对象注入"升级成"代码执行"的一般步骤
-
审计目标源码,找出所有可被序列化的类 ,定位危险点(
system/eval/file_put_contents/include/反序列化嵌套调用...); -
用你的反序列化功底把类串成 POP 链 ,目标:某对象
__destruct(或经跳板)最终调用危险函数; -
用 4.3 的方法
serialize()出整条链的字符串; -
处理可见性属性(public/protected/private 前缀
\0)与不可序列化属性; -
拼成
|链字符串提交到写点; -
让目标以
php处理器访问含该 session 的页面 → 触发。
练手时,把 sink 定为
__destruct里 echo 一句话 / 写文件,就能直观验证。