反序列化漏洞的根因,是应用将客户端可控的数据交给危险的反序列化机制处理。Gadget Chain 则是攻击者利用目标应用及其依赖中已经存在的代码,将可控数据逐步传递到危险操作的一种手段。
分析这类漏洞时,需要先区分两个问题:
服务器是否真的执行了反序列化?
以及:
目标环境中是否存在能够到达危险 Sink 的 Gadget Chain?
URLDNS 和 JRMPClient 主要用于检测 Java 反序列化是否发生;PHPGGC 则用于根据 PHP 框架及其依赖生成预构建的 Gadget Chain。它们解决的问题不同,也不能跨语言混用。
一、URLDNS:通过 DNS 查询检测 Java 反序列化
URLDNS 的目标不是执行命令,而是制造一个能够从外部观察的 DNS 查询,以确认服务器是否处理了 Java 序列化对象。
理解 URLDNS,首先需要理解 HashMap。
HashMap 为什么需要 hashCode()
HashMap 是 Java 中保存 Key 与 Value 对应关系的数据结构。例如:
alice → Alice 的用户资料
bob → Bob 的用户资料
carlos → Carlos 的用户资料
为了避免每次查询都遍历全部数据,HashMap 会先调用 Key 的:
key.hashCode()
得到一个整数,再根据这个整数确定数据应当保存在哪个桶中。
Key
→ hashCode()
→ 得到哈希值
→ 计算桶位置
→ 保存或查找数据
这里的哈希值不是为了加密,而是为了提高数据查找效率。
当 HashMap 被反序列化时,服务器需要重新建立其内部桶结构。为了把每个 Key 放回正确的位置,HashMap 会再次计算 Key 的哈希值:
服务器恢复 HashMap
→ 读取 Key 和 Value
→ 调用 Key.hashCode()
→ 重新建立桶结构
URLDNS 正是利用了这个自动调用行为。
URL 对象为什么会产生 DNS 查询
URLDNS 将一个 java.net.URL 对象放入 HashMap 的 Key 中:
HashMap
└── Key:URL 对象
└── host:攻击者提供的唯一域名
服务器反序列化时:
HashMap 恢复桶结构
→ 调用 URL Key 的 hashCode()
→ URL 处理其中的主机名
→ Java 尝试解析主机名
→ 服务器发出 DNS 查询
如果 URL 中使用 Burp Collaborator 提供的唯一域名,那么服务器产生的 DNS 查询就会被 Collaborator 记录。
完整因果链是:
ObjectInputStream.readObject()
→ HashMap.readObject()
→ 重建 HashMap
→ URL.hashCode()
→ 主机名解析
→ DNS 查询
ysoserial 还会处理 URL 对象内部的哈希缓存,确保目标服务器需要重新计算哈希值。否则,DNS 查询可能在 Payload 生成阶段就发生在本地,而不是发生在目标服务器上。
URL.hashCode() 是普通方法还是 Gadget
URL.hashCode() 首先是 Java 标准库中真实存在的普通方法。它不是 ysoserial 添加到目标服务器上的代码,也不是专门为攻击设计的函数。
Gadget 并不是 Java 中的一种特殊语法类型,而是一种安全分析中的角色:
目标环境中原本存在、能够被非预期触发,并产生攻击者所需效果的代码组件。
正常情况下,URL.hashCode() 只是计算 URL 对象的哈希值。但在 URLDNS 中,它同时满足:
- 目标服务器上存在该方法;
- HashMap 反序列化时会自动调用它;
- 攻击者能够控制 URL 中的主机名;
- 它能够继续触发主机名解析;
- DNS 查询能够被攻击者从外部观察。
因此,URL.hashCode() 是 Java 标准库中的合法方法,但在 URLDNS 这条特定调用链中承担了 Gadget 的作用。
更准确地划分:
HashMap.readObject()
→ 启动对象恢复和哈希计算
URL.hashCode()
→ 将执行流引向主机名处理
DNS 解析
→ 产生最终可观察的网络副作用
URLDNS 的证据边界
如果 Collaborator 收到 DNS 查询,可以证明:
- 目标处理了提交的 Java 序列化数据;
- HashMap、URL 等 URLDNS 所需的 JDK 类可以使用;
- 反序列化过程触发了 URL 主机名解析;
- 服务器具备可观察的 DNS 出站能力。
但它不能直接证明:
- Apache Commons Collections 存在;
- 某条 Commons Collections Gadget Chain 可以使用;
- 服务器没有配置对象过滤;
- 调用链能够到达命令执行函数;
- Java 进程拥有执行目标命令所需的系统权限。
原因在于 URLDNS 使用的是 Java 标准库中的 HashMap 和 URL,没有经过 Apache Commons Collections。
因此:
URLDNS 成功
≠ Commons Collections 存在
≠ RCE Gadget Chain 可用
≠ 任意命令执行成立
同样,URLDNS 没有回连也不能证明目标安全。失败还可能来自 DNS 出站限制、对象过滤、Payload 损坏、DNS 缓存,或者数据根本没有到达反序列化入口。
二、JRMPClient:通过 TCP 连接和时间差检测
JRMPClient 同样主要用于检测 Java 反序列化,但它观察的不是 DNS 查询,而是 TCP 连接行为。
JRMP 是 Java Remote Method Protocol,主要用于 Java RMI 通信。JRMPClient Payload 中包含一个远程地址:
IP:端口
服务器反序列化该对象时,Java 的 RMI/JRMP 处理逻辑会尝试连接这个地址:
服务器执行反序列化
→ 恢复 JRMP 远程引用
→ Java 处理远程端点
→ 尝试建立 TCP 连接
如果指定的是受控监听地址,并且监听端记录到连接,就能确认服务器处理了 JRMPClient 对象。
为什么可以通过响应时间检测
在无法直接接收连接的情况下,还可以比较不同网络状态下的响应时间。
连接一个会快速拒绝连接的地址时:
服务器发送 TCP SYN
→ 对端立即返回 RST
→ 连接快速失败
→ HTTP 请求较快返回
连接一个被防火墙静默丢弃的地址时:
服务器发送 TCP SYN
→ 没有收到任何响应
→ 操作系统等待并重试
→ 连接最终超时
→ HTTP 请求明显变慢
因此,可以只改变 Payload 中的 IP,进行重复对照:
快速失败地址
→ 响应较快
静默丢弃地址
→ 响应明显变慢
如果这种差异能够稳定复现,就支持"服务器在反序列化过程中尝试建立 TCP 连接"的判断。
不过,时间差仍然属于间接证据,需要排除:
- 服务器负载变化;
- 代理或网络抖动;
- 防火墙主动拒绝而不是静默丢弃;
- 应用自身设置的连接超时;
- 多次请求之间的缓存或连接复用。
JRMPClient 能够证明的是:
反序列化过程触发了 TCP 连接尝试
它同样不能单独证明任意命令执行。
URLDNS 和 JRMPClient 都包含在 ysoserial 中,不是两个需要单独下载的工具。PortSwigger 对检测型 Gadget Chain 的说明
三、PHPGGC:利用 PHP 框架中的预构建 Gadget Chain
PHPGGC,即 PHP Generic Gadget Chains,是 PHP 生态中的预构建 Gadget Chain 工具。它收录了 Symfony、Laravel、Monolog、Doctrine、CodeIgniter 等框架或组件中的已知对象调用链。
PHPGGC 不会向目标服务器植入新的代码。它只是按照目标框架中已有的类关系,在本地生成一个特殊的 PHP 序列化对象:
目标框架中已经存在相关类
→ PHPGGC 按照已知关系组织对象属性
→ 生成序列化对象
→ 目标执行 unserialize()
→ magic method 自动启动调用链
→ 攻击者数据到达危险 Sink
因此,使用 PHPGGC 前必须回答:
目标使用什么语言?
目标使用什么序列化机制?
目标使用什么框架和版本?
服务器加载了哪些相关类?
哪条 Gadget Chain 与该版本兼容?
数据是否会进入 unserialize()?
是否存在签名、加密或对象过滤?
仅仅发现 PHPGGC 中存在某条链,不能证明目标存在反序列化漏洞。真正的漏洞仍然是对客户端可控数据执行危险反序列化,Gadget Chain 只是利用手段。
如何区分 PHP 与 Java 原生序列化
不能只凭实验说明判断数据属于 PHP 还是 Java,应当直接检查解码后的序列化格式。
本次 Cookie 的 Base64 token 解码后类似:
O:4:"User":2:{
s:8:"username";
s:6:"wiener";
...
}
这符合 PHP serialize() 的格式:
O:<类名长度>:"<类名>":<属性数量>:{...}
PHP 常见类型标记包括:
i:123; integer
b:1; boolean
s:5:"hello"; string
a:2:{...} array
O:4:"User":2:{...} object
Java 原生序列化则是二进制对象流,原始数据通常以以下魔数开头:
AC ED 00 05
Base64 编码后经常以:
rO0AB...
开头。
Java 对象流中同样会出现可读的类名和属性名,所以从 Burp 中查看时可能感觉与 PHP 对象相似。但 Java 类名周围存在大量二进制协议标记,而 PHP 则具有明显的类型、长度、分号和大括号语法。
因此,本次实验中的判断依据是:
Base64 解码
→ 出现 O:4:"User":2:{...}
→ 符合 PHP serialize() 语法
→ 判断为 PHP 原生序列化对象
随后错误响应中出现 Symfony 4.3.6,是对 PHP 框架和版本的进一步识别,而不是判断 PHP 序列化格式的唯一依据。
需要注意,Java 应用不一定使用 Java 原生序列化,PHP 应用也不一定使用 serialize()。语言、框架和序列化格式需要分别判断。Java 原生序列化协议、PHP serialize() 官方说明
四、Symfony 签名 Cookie 实验:从框架指纹到 Gadget Chain
本次 PortSwigger Lab 使用序列化对象保存 Session,同时使用 HMAC-SHA1 对 Cookie 内容进行签名。实验展示了完整利用链中三个关键问题:
- 如何识别序列化格式和目标框架;
- 如何突破签名 Cookie 的完整性保护;
- 如何选择与目标版本兼容的预构建 Gadget Chain。
识别 Cookie 的封装结构
原始 Cookie 经过 URL 解码后,结构类似:
{
"token": "Base64编码的数据",
"sig_hmac_sha1": "HMAC-SHA1签名"
}
继续对 token 进行 Base64 解码,可以看到 PHP 序列化对象。
整个封装关系是:
URL 编码的 Cookie
→ JSON
→ token + sig_hmac_sha1
→ token 进行 Base64 解码
→ PHP 序列化对象
这说明客户端持有的是一个经过签名保护的 PHP 序列化对象。
签名保护解决了什么问题
HMAC-SHA1 不负责隐藏 token,而是验证它是否被不知道密钥的人修改。
原 token + 原签名
→ 验证通过
修改后的 token + 原签名
→ 验证失败
服务器大致按照以下顺序处理:
URL 解码 Cookie
→ 解析 JSON
→ 取出 token 和 sig_hmac_sha1
→ 使用 SECRET_KEY 重新计算 HMAC-SHA1
→ 比较签名
→ 签名正确后 Base64 解码 token
→ 执行 unserialize()
因此,即使 PHPGGC 能生成恶意对象,如果没有签名密钥,也无法让恶意 Cookie 通过完整性验证。
通过错误响应识别框架
实验中只修改签名的一个字符,同时保持 JSON 和 Base64 token 不变,服务器返回:
Internal Server Error: Symfony Version: 4.3.6
这是一个只改变单一变量的对照实验:
正常签名
→ 请求正常处理
错误签名
→ 验证失败
→ 错误响应泄露 Symfony 4.3.6
框架版本的作用,是帮助选择兼容的 PHPGGC Gadget Chain。它不是漏洞本身,也不能单独证明命令执行成立。
/cgi-bin/phpinfo.php 是如何发现的
该路径不是根据 Symfony 版本计算出来的,也不是因为所有 PHP 网站都会存在这个地址。
本 Lab 的正常页面 HTML 中遗留了开发者注释,注释中暴露了调试页面路径:
/cgi-bin/phpinfo.php
HTML 注释不会显示在正常渲染页面中,但仍然属于服务器响应内容,因此可以在以下位置发现:
- Burp 的 Response Raw;
- 浏览器"查看页面源代码";
- 对响应内容搜索
phpinfo、cgi-bin或Debug。
官方解题说明同样明确指出,该路径来自开发者注释,而不是来自 Symfony 错误响应。PortSwigger 官方 Lab
这一步可以提炼为一般化的识别方法:
检查正常响应中的 HTML 注释
→ 检查 JavaScript 和静态资源
→ 检查错误响应中的内部路径
→ 检查公开的调试和诊断端点
不能将 /cgi-bin/phpinfo.php 当成所有 PHP 应用的固定路径,它只是这个 Lab 特意留下的线索。
phpinfo 为什么会泄露密钥
phpinfo() 是 PHP 的诊断函数,可以输出当前 PHP 环境、配置、模块、请求信息和环境变量。
本 Lab 将 Cookie 签名密钥保存在服务器进程的环境变量:
SECRET_KEY
调试页面调用 phpinfo() 后,环境变量被直接输出到 HTTP 响应中:
<td class="e">SECRET_KEY</td>
<td class="v">...</td>
所以攻击者不是根据 HMAC 结果"破解"了密钥,而是:
开发者注释泄露调试页面路径
→ 请求 phpinfo 页面
→ phpinfo 输出服务器环境变量
→ HTTP 响应直接泄露 SECRET_KEY
HMAC 算法本身没有被绕过。失效的是密钥保密性。
只要密钥仍然保密,攻击者无法根据一个 HMAC-SHA1 结果直接反推出密钥。但一旦服务器直接泄露密钥,攻击者就可以为任意新 token 生成合法签名。PHP phpinfo() 官方说明
匹配 Symfony Gadget Chain
错误响应确认目标使用 Symfony 4.3.6。PHPGGC 中的 Symfony/RCE4 覆盖对应版本范围,因此可以生成与目标类结构兼容的对象。
利用过程可以概括为:
识别 PHP 序列化格式
→ 确认 Symfony 4.3.6
→ 选择兼容的 Symfony/RCE4
→ PHPGGC 生成恶意序列化对象
→ 按 Cookie 要求进行 Base64 编码
→ 使用泄露的 SECRET_KEY 计算新 HMAC
→ 重新组成签名 Cookie
→ 服务器验签通过
→ 服务器执行 unserialize()
→ __destruct 启动 Gadget Chain
→ 攻击者数据到达命令执行 Sink
具体 Payload 并不是本实验最值得记忆的部分。更重要的是理解:PHPGGC 只负责生成中间的序列化对象,应用特有的编码、签名和 Cookie 封装仍然需要单独分析。
为什么命令执行后仍然返回 500
提交恶意 Cookie 后,服务器返回:
Call to a member function saveDeferred() on null
但 Lab 仍然成功。
原因是 Symfony/RCE4 的危险函数调用发生在报错之前:
TagAwareAdapter::__destruct()
→ commit()
→ invalidateTags()
→ ProxyAdapter->saveDeferred()
→ ProxyAdapter->doSave()
→ 调用攻击者指定的函数
→ 命令执行
→ 程序继续处理缓存对象
→ pool 属性为 null
→ 调用 saveDeferred() 时报错
PHPGGC 不需要构造一个功能完整的 Symfony 缓存对象,只需要让对象具备足够的字段,使程序先到达危险调用位置。后续对象状态不完整,仍然可能产生异常,但已经完成的文件删除等副作用不会被撤销。
因此:
HTTP 500
≠ Gadget Chain 一定失败
判断是否利用成功,应结合:
- 错误发生在调用链的哪个位置;
- 危险 Sink 是否位于错误之前;
- 预期副作用是否出现;
- Lab 状态是否变为
Solved。
本次实验中,Lab 显示 Solved 是最终结果证据,500 响应只是 Gadget Chain 执行后的尾部异常。
从实验提炼出的通用分析框架
URLDNS、JRMPClient 和 PHPGGC 可以放入一套分层分析模型:
识别输入格式
→ 判断数据是否由客户端控制
→ 确认服务器是否真的反序列化
→ 判断语言和序列化机制
→ 识别框架、依赖与版本
→ 检查签名、加密和对象过滤
→ 匹配可用 Gadget Chain
→ 确定入口 Gadget
→ 跟踪攻击者数据如何传递
→ 确定最终 Sink
→ 使用最低影响方式验证结果
其中:
URLDNS
→ 通过 DNS 副作用确认 Java 反序列化
JRMPClient
→ 通过 TCP 连接或时间差确认 Java 反序列化
PHPGGC
→ 根据 PHP 框架和版本生成预构建 Gadget Chain
检测链回答的是"服务器是否处理了对象",利用链回答的是"现有代码能否把攻击者数据带到危险操作"。两者不能混为一谈。
防御思路
最根本的防御,是避免对客户端可控数据执行原生对象反序列化。Session 状态应优先保存在服务器端,客户端只保存不可预测的 Session ID。
如果业务确实需要处理序列化数据,还应采取以下控制:
- 只接受必要的基础数据类型;
- 使用严格的类型白名单;
- Java 配置适当的
ObjectInputFilter; - PHP 谨慎使用
unserialize()并限制allowed_classes; - 在反序列化前验证数据完整性;
- 安全保存签名密钥并定期轮换;
- 禁止生产环境暴露
phpinfo()和调试页面; - 不在 HTML 注释、JavaScript 和错误响应中泄露内部路径;
- 关闭详细框架错误信息;
- 移除不必要的依赖并及时升级框架;
- 限制应用进程的文件、网络和命令执行权限;
- 限制不必要的 DNS 与 TCP 出站连接。
签名 Cookie 只能在密钥保密的前提下保护数据完整性。它不能消除反序列化本身的危险。一旦密钥通过调试页面、源代码、日志或配置泄露,攻击者就可以为恶意对象生成合法签名。
本章总结
本章需要区分四个核心概念:
反序列化不可信数据
= 漏洞根因
Gadget
= 目标环境中可被复用的现有代码
Gadget Chain
= 多个 Gadget 组成的可达调用路径
Sink
= 最终执行敏感操作的位置
URLDNS 利用 HashMap 在恢复桶结构时调用 URL.hashCode(),通过 DNS 查询确认 Java 反序列化;JRMPClient 利用 RMI/JRMP 远程引用处理,通过 TCP 连接或响应时间差提供另一种检测方式;PHPGGC 则根据 PHP 框架中的已知类和 magic method 生成预构建对象链,将反序列化推进到文件操作或命令执行。
在 Symfony Lab 中,最终利用成立不是因为单一漏洞,而是多个条件同时出现:
客户端控制序列化 token
+ 服务器执行 PHP 反序列化
+ 错误响应泄露 Symfony 版本
+ 开发者注释泄露调试路径
+ phpinfo 泄露 HMAC 密钥
+ 目标加载兼容的 Symfony Gadget
+ PHPGGC 生成匹配对象
+ 恶意 token 被重新合法签名
= Gadget Chain 到达命令执行 Sink
这也是分析反序列化漏洞时最值得迁移的思路:不要只问"有没有现成 Payload",而要逐层确认输入格式、反序列化入口、目标依赖、自动触发机制、完整性保护、数据传递路径和最终 Sink。