Fastjson and c3p0 Secondary Deserialization: Risk Analysis and Memory-Resident Component Detection

免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。

本文讨论的风险点并不新鲜,但在实际系统里的出现频率始终不低,因此值得从防守角度重新梳理一遍。起因是作者在设计一道 CTF 题目时遇到的问题:应用使用 Spring 与 Fastjson,但没有配置反序列化白名单,运行环境不出网,且运行在较高版本的 JDK 上。这三个条件叠加之后,依赖远程地址的利用路径全部失效,常规 Gadget 的可用性也明显下降,于是攻击者会转向第三方组件寻找新的反序列化入口,并倾向于用纯内存驻留的方式维持访问。

这类技术本身并不新颖,难的是判断它是否适用于自己的系统。真正值得记录的是它的成立条件:哪一类组件会额外提供反序列化入口,什么样的网络与权限环境会推动攻击者选择内存驻留,以及在防守侧应该看哪些信号才能把这类行为识别出来。本文只从成因、识别与治理三个角度展开,不提供可复用的载荷、报文与注入步骤;文中出现的依赖版本、类名与调用路径均来自本地授权实验环境的记录,用于说明技术原理与检测点。

IceCliffs

实验环境使用的依赖为 c3p0 0.9.5.2、commons-collections 3.1 与 fastjson 1.2.83,三者共同决定了风险能否成立:一个提供二次反序列化入口,一个提供可用的 Gadget,一个把外部数据送到入口。需要额外注意的是示例入口的写法——静态块中对全局实例开启了 autoType,同时路由把请求体字符串直接交给 JSON.parse。前者会抵消 Fastjson 版本升级带来的大部分缓解效果,后者则让任意请求体都能驱动类型实例化。

风险成因与技术原理

Fastjson 的风险模型可以概括为「数据与类名合流」:请求体中的 @type 字段用于指定反序列化的目标类型,当 autoType 处于开启状态时,解析器会依据该字段加载并实例化对应的类,并调用其构造方法与 setter。只要入口把不可信数据交给按类型名实例化的解析接口,且进程内存在可用的 Gadget 类,风险就已经成立,与是否有人真的发起攻击无关。

官方的白名单与黑名单机制是一段持续的对抗:黑名单依赖已知类名的枚举,而 c3p0、数据库连接池、ORM 数据源这一类第三方组件并不在 Fastjson 自身维护的 Gadget 视野内,因此它们成为绕开限制时优先被审视的目标。c3p0 是 Java 生态中历史较久的数据库连接池实现,早期项目大量直接使用 ComboPooledDataSource,其类实现或持有 Referenceable 相关属性,这就为「第一次反序列化之后仍然存在第二个反序列化入口」提供了条件。

Reference 间接序列化与远程类加载

以 com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase 为例。它的 writeObject 会先尝试序列化 connectionPoolDataSource 属性,而该属性通常是一个不可序列化的接口实现,序列化过程抛出 NotSerializableException 后进入 catch 分支,转而调用 ReferenceIndirector#indirectForm:通过 getReference() 取出对象的 Reference 描述,封装成 ReferenceSerialized 对象作为替代形式写入流中。

image-20260307174714099

反序列化侧对应的是 ReferenceSerialized#getObject,它进一步调用 com.mchange.v2.naming.ReferenceableUtils#referenceToObject,由后者负责把 Reference 描述还原成实际对象。

image-20260307174757765

风险正出现在这一步:referenceToObject 会读取 Reference 中的工厂类名与 factoryClassLocation,当本地不存在目标类时,c3p0 会新建一个 URLClassLoader,依据该地址去加载并实例化这个工厂类。问题在于,Reference 的内容随被序列化的对象一同传递,其来源是否可信并不由 c3p0 校验。只要上游数据能够影响这一字段——例如 JSON 属性直接绑定到该持有对象——就形成了一个由数据驱动的类加载点。

image-20260307175907094

从调用链角度,这条路径可以归纳为:

1
2
3
4
5
PoolBackedDataSourceBase#readObject()
-> ReferenceSerialized#getObject()
-> ReferenceableUtils#referenceToObject()
-> Class#forName(className, true, urlClassLoader)
-> ObjectFactory#getObjectInstance()

ReferenceableUtils#referenceToObject 中的相关处理逻辑,是这条链路的关键位置:类名与位置信息来自被反序列化的数据,而加载动作由组件自己完成,中间没有可信性判断。

image-20260307180957626

需要区分的是层次:这条路径由 c3p0 自行创建 URLClassLoader 完成,与 JNDI 层面的 trustURLCodebase 一类开关并不是同一处控制点,仅在 JNDI 侧收紧配置并不能覆盖它。反过来,在不出网环境中由于目标地址不可达,这条路径也会自然失效,攻击者只能转向不依赖外部网络的入口。

image-20260307185823253

JNDI 名称可控的风险

与上述路径同源的是 com.mchange.v2.c3p0.JndiRefForwardingDataSource:其 jndiName 属性决定了 JNDI 查找的目标,如果该属性可以被外部数据控制,就等于把「去哪里取对象」的决定权交给了数据来源方,构成标准的 JNDI 注入条件。链路细节此处不再展开,值得记录的是判断方式——反序列化框架在实例化对象时会自动匹配字段与 setter,凡是语义上指向「地址、名称、路径、类名」的属性,只要能由外部数据写入,都应被当作危险汇聚点对待。

userOverridesAsString 与二次反序列化

真正让 c3p0 在「不出网 + 高版本 JDK」条件下依然有价值的,是 com.mchange.v2.c3p0.WrapperConnectionPoolDataSource 上的字符串属性 userOverridesAsString。其恢复流程为:PoolBackedDataSourceBase#readObject → WrapperConnectionPoolDataSource#readObject → C3P0ImplUtils#parseUserOverridesAsString(String)。解析函数会识别 HexAsciiSerializedMap: 前缀,把前缀之后的文本经 ByteUtils#fromHexAscii 做十六进制解码,再交给 SerializableUtils#fromByteArray → deserializeFromByteArray,而后者内部直接新建一个 ObjectInputStream 并调用 readObject()。

image-20260307190928958

也就是说,一个声明为字符串的配置属性,在恢复阶段被转换成了二进制流。解析函数不仅负责解析字符串,还硬编码了对 HexAsciiSerializedMap 这种特殊格式的处理,把原本应当是纯文本的配置变成了原始序列化数据的容器。

image-20260307191022206

继续跟进到 SerializableUtils#fromByteArray 与内层的 deserializeFromByteArray,可以看到入口最终落在原生反序列化上。

image-20260307191137589

内层直接新建 ObjectInputStream 并调用 readObject() 这一点,是理解该风险的关键:JSON 或 XML 解析层的黑名单、字段校验与网关上的关键字规则都不作用于这段二进制内容,它们看到的只是属性值里的一串十六进制文本。

image-20260307191148326

由此可以把「二次反序列化」的含义说清楚:外层由 Fastjson 依据 @type 实例化 c3p0 对象并完成属性绑定,内层由 c3p0 自己把属性字符串还原为字节流后再次交给原生反序列化,同一个请求里连续发生了两次反序列化,而第二次发生在任何 JSON 层安全控制的视野之外。内层可利用的范围取决于目标进程的 classpath,commons-collections 3.1 这类历史依赖会显著抬高可利用性。

从流量角度看,这种设计留下了一个非常明确的痕迹:一个长度远超正常配置项的十六进制文本字段。

image-20260307191919055

需要留意的是,这类组件的设计初衷是把数据库连接池的部分配置以可序列化形式保存下来,并不是为了提供安全边界。它成为风险点的原因在于:属性值与类加载、对象还原之间存在隐式耦合,而组件并不对属性来源做可信性判断。治理的重点也因此不在于组件的版本号本身,而在于确认它是否确为业务所需,以及它的这些属性有没有可能被外部数据写入。

Fastjson 反序列化入口的风险识别

回到题目所处的场景。在不出网的网络环境中,依赖远程地址的路径全部不可用,剩下的选择是把功能直接在目标进程内实现,也就是通常所说的内存驻留组件。这类驻留方式对防守方的不利之处在于:不落地文件、重启即消失,磁盘扫描与文件完整性校验都无法发现它,而它在行为上又和正常业务流量混在一起。

在不出网场景下被反复提及的数据源组件,主要是 com.mchange.v2.c3p0.WrapperConnectionPoolDataSource、org.apache.tomcat.dbcp.dbcp.BasicDataSource、org.apache.tomcat.dbcp.dbcp2.BasicDataSource 以及 org.apache.ibatis.datasource.unpooled.UnpooledDataSource。从防守角度,这组类名本身就是一份可用的依赖侧检测特征:如果业务代码并不使用这些组件,它们却因为传递依赖出现在运行时的 classpath 中,就是一个需要记录的风险项,因为它们同时具备「可绑定外部属性」与「内部会再次反序列化」两种特性。

不出网环境下的内存驻留方式与成因

Spring 应用的请求处理由 RequestMappingHandlerMapping 统一编排,其内部保存着处理器拦截器的集合(通常为 adaptedInterceptors),而 HandlerInterceptor#preHandle 会在请求进入 Controller 之前执行。这类内部集合在运行时只是普通对象的成员字段,如果进程内已经具备代码执行能力,就可以在内存中构造一个实现 HandlerInterceptor 接口的对象实例并注册进去,从而获得后续每一次请求的执行权。以 Filter、Servlet、Listener 形式动态注册的组件,效果与此等价。

成因可以归纳为三点。其一,动态注册不经过部署描述符,不产生磁盘文件,因此不会出现在发布包与配置文件的一致性比对结果里。其二,这类组件的类通常不是从磁盘加载的,而是由运行时的字节码定义产生,例如 TemplatesImpl 所携带的字节码在运行时被定义为类,因而没有对应的 class 文件可供检索。其三,重启后一切归零,行为上不留下持久化痕迹,除非攻击者另外写了启动脚本或替换了容器组件。

工具化是近几年的另一个变化。公开的集成工具把载荷构造、字节码封装与序列化步骤整理为可视化流程,显著降低了利用门槛。

image-20260307195308682

这意味着该类风险的现实发生率不再取决于攻击者的个人技术能力,而取决于资产是否具备前置条件——可控的反序列化入口、可用的 Gadget、以及足够的进程权限。

image-20260307194029800

因此下文的检测与排查部分,重点放在前置条件的核查上,而不是猜测具体的工具与载荷形态:只要入口与组件还在,任何一种工具都能达到同样的效果。

以字节码形式在内存中定义类,是这类驻留方式得以成立的技术基础,也是磁盘侧检测手段失效的直接原因。

image-20260307215449250

同类驻留组件的共性特征

以哥斯拉(Godzilla)这类内存马管理工具为例,其驻留形态与上文所述的拦截器型组件在成因上完全一致:都是借助已经获得的代码执行能力,把处理请求的组件动态注册到 Web 容器或框架的运行态结构中,再由管理端通过特定参数识别属于自己的请求。不同工具之间的差别主要在通信隐蔽性与参数命名,而对防守方来说,判断依据始终是同样三件事——组件的注册来源、对应类文件的落地情况、以及重启之后是否复现。

检测方法与可观测特征

检测的价值在于把「是否被利用过」这个问题转化为具体可查的信号。以下信号在授权环境的验证中均可复现,且不依赖特定工具。

请求与流量层。请求体中出现的 @type 字段本身是 Fastjson 反序列化的强特征,需要重点关注的是它的取值:指向 com.sun.rowset.JdbcRowSetImpl、com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl、com.mchange.v2.c3p0 下的 WrapperConnectionPoolDataSource 与 JndiRefForwardingDataSource、org.apache.tomcat.dbcp 下的数据源类、org.apache.ibatis.datasource 下的数据源类等非业务类名时,应视为明确告警。字段名同样是指标:userOverridesAsString、jndiName、dataSourceName、factoryClassLocation 这类语义指向名称、地址或类名的属性,出现在业务不需要的位置就值得追查。属性值以 HexAsciiSerializedMap: 开头、其后跟随大段十六进制文本,是二次反序列化的确定性标志;与之伴随的通常是远超正常配置项的字段长度。此外,Content-Type 为 JSON 而请求体达到数十 KB、来自业务不常见来源的 POST 请求,也都是有效的筛选条件。

image-20260307215341697

出网探测行为。部分载荷在外层就需要确认网络可达性,会在出口处留下解析失败或非常规域名的 DNS 查询、以及目标不可达的 HTTP 连接尝试。在本身就不出网的环境中,这类探测集中表现为大量解析超时与连接失败,是很容易被忽略的异常记录。

日志与异常层。应用日志中由 JSON.parse 抛出的 autoType is not support 一类提示,往往说明入口正在被探测,而不是业务出错。堆栈中的 NotSerializableException、针对 Gadget 类名的 ClassNotFoundException,以及 ClassCastException 出现在与反序列化无关的业务流程里,都值得单独统计并纳入告警。

image-20260307212714631

JVM 运行时层。真正接近执行路径的信号在 JVM 内部:可疑类被动态定义(已加载类列表中出现与发布包不对应的类名、类加载器数量或命名异常)、defineClass 相关调用、以及 Instrumentation 的 retransformClasses 与 redefineClasses 被非预期地使用。这些信号的共同特点是需要基线——只有在发布时留存了正常运行状态下的类清单与加载器清单,异常才可辨认。

image-20260307215636479

进程与文件系统层。运行期在临时目录(例如 /tmp)出现尺寸固定、命名随机的文件,进程持有的异常网络连接与无名线程,都可能是驻留组件的副产品。这类信号单独看都很弱,需要与请求日志关联后再判断。

驻留组件排查与处置

排查思路可以按「注册来源—类是否落地—内存类清单—重启复现」四步推进,每一步都只依赖常用运维工具。

第一步,比对注册项与发布包。把运行时的 Filter、Servlet、Listener、拦截器、Controller 注册列表逐一与发布包中的声明(web.xml、注解扫描结果、框架配置)对照。任何只在运行时存在、在发布包中找不到对应声明的注册项,都应视为可疑对象并记录其类名、类加载器与注册时间。

第二步,检查类是否落地。拿着第一步得到的类名,到应用目录、容器 lib 目录、临时目录中检索同名 class 文件。动态注册的驻留组件通常没有对应文件,其类往往由字节码在运行时定义,加载器不是应用类加载器,命名也可能刻意伪装成框架常用名。

第三步,做内存镜像比对。JDK 自带工具(jcmd、jmap、jstack)与常见诊断工具(Arthas 等)在这里的作用是做三件事:导出并检索已加载类列表,与干净基线或发布包中的类清单做差分;检查线程栈中是否存在与业务无关的常驻线程及其调用位置;观察是否存在类被重复定义或由非预期加载器加载的情况。同样的道理,这类排查的效果取决于基线质量,建议在发布环节就把类清单与依赖清单留存下来。

第四步,区分内存驻留与持久化驻留。如果在磁盘上找不到对应组件,清理后重启即不再出现,基本可以判定为纯内存驻留;如果重启后仍然复现,就需要继续检查持久化位置——web.xml、框架配置文件、启动参数、被替换过的容器 jar 包、计划任务与启动脚本。

image-20260307215144750

处置顺序建议为:先留存证据(内存镜像、类清单、访问日志、网络连接记录与关键进程信息),再做隔离(摘除流量或下线实例),最后清理与重建。对内存驻留组件,直接删除磁盘文件通常没有意义,可靠做法是用校验过的发布包重建实例,或替换被篡改的容器与依赖;在入口漏洞修复完成之前,不建议恢复流量。

image-20260307215156678

修复与加固

关闭 autoType 是第一件事。在应用启动阶段显式调用 ParserConfig.getGlobalInstance().setAutoTypeSupport(false),并在可行时启用安全模式(ParserConfig.getGlobalInstance().setSafeMode(true),也可通过 JVM 启动参数 -Dfastjson.parser.safeMode=true 全局生效)。安全模式一旦开启,解析器不再依据数据中出现的类型名去实例化任意类,这比维护黑名单可靠得多。同时检查是否存在散落在静态块或配置类中的全局开启语句——这类配置往往在排查时最容易被漏掉。

第二件事是版本与组件治理。升级到官方仍在维护的最新稳定版本;对确实无法升级的老系统,至少要保证 autoType 处于关闭状态并启用安全模式。业务允许时,可以评估迁移到不再依据数据中的类型名实例化对象的解析方案;使用 Jackson 时需确认未开启基于数据中类型信息的默认类型(default typing)能力。

第三件事是清理不受控依赖。c3p0 与 commons-collections 3.1 的组合是历史风险高发点:前者提供二次反序列化入口,后者提供可用的 Gadget。对已经不再使用的连接池、缓存与序列化组件做依赖清理,不要因为「没有直接引用」就默认它们不存在——传递依赖同样会进入运行时 classpath。依赖清单应纳入构建产物,并在发布环节做比对。

第四件事是入口侧收口。不要把不可信数据交给按类型名实例化的解析接口。如果必须保留 Fastjson 兼容入口,应使用指定目标类型的解析方法(例如 JSON.parseObject(body, 明确的目标类)),并配合 ParserConfig 的接受与拒绝机制把可参与反序列化的类收敛到业务必需的最小集合。在此基础上,对属性绑定做二次校验:凡是语义指向地址、名称、路径、类名的字段,都不允许由外部输入写入。

第五件事是白名单化的持续推进。白名单需要跟着业务演进维护,但它的收益是明确的:即使某个组件重新引入了一个反序列化入口,只要其类不在白名单中,数据驱动的实例化就不会发生。

分层防护

单一控制点的效果有限,这类风险的可靠收敛依赖分层。

供应链与依赖治理。在构建期生成依赖清单或 SBOM,用 SCA 工具(OWASP Dependency-Check、Trivy 以及 Maven/Gradle 的依赖树输出等)在构建与发布环节比对已知漏洞与版本;把「是否存在不受控的反序列化入口组件」纳入上线检查项。

最小权限。应用账户只应拥有业务必需的目录权限;容器以只读根文件系统运行,发布目录、容器 lib 与临时目录不可写,避免运行期生成组件或篡改依赖包;限制容器能力,不挂载容器运行时套接字。

出网管控。对应用出口实施默认拒绝的白名单策略。即便部分载荷不依赖出网,出口管控也能让回连与探测失败,并留下可检测的记录;内网东西向流量同样需要审计,否则一台机器被控之后横向移动没有任何阻力。

运行时防护。RASP 或 Java Agent 形态的运行时监控在反序列化、类定义、命令执行、表达式求值等关键位置设置 Hook,比网络层的特征匹配更接近真实执行路径;对非预期的类定义与 Instrumentation 调用应配置告警。

网关与 WAF。在网关侧对报文中的 @type、HexAsciiSerializedMap: 以及前述类名字符串做特征检测与阻断,是有价值的兜底。但要清楚它的局限:规则依赖已知特征,字段名与编码方式都可以变形,C3P0 内层的载荷本身就是十六进制文本,容易绕过基于分词的规则;HTTPS 与加密流量还会进一步削弱可见性。因此网关规则不能替代关闭 autoType 与依赖清理,只能作为纵深防御中的一层。

可观测性。把上面几层的信号统一纳入告警:出网失败、类定义异常、组件注册变化、临时目录写入、反序列化异常集中出现。这些信号单独看都不强,但组合起来足以在早期识别出利用行为。

生产环境排查清单

  1. 依赖核查:导出全部运行实例的依赖清单(依赖树插件输出、打包后的 jar 列表均可),确认是否存在 Fastjson、c3p0、commons-collections 3.1 等组件及其版本,尤其关注并未在代码中直接使用、仅由传递依赖引入的组件。
  2. 配置核查:检索代码与配置中是否存在全局开启 autoType 的调用,是否存在未启用安全模式的 Fastjson 依赖,是否存在把请求体字符串直接交给 JSON.parse 的通用入口。
  3. 入口梳理:列出所有接受 JSON 的接口,特别是 Spring 的 @RequestBody 与手写解析两类,标注鉴权与限流情况,确认不存在无差别接收任意类型名的路由。
  4. 日志检索:在应用日志与网关日志中检索 @type、HexAsciiSerializedMap、userOverridesAsString、JdbcRowSetImpl、TemplatesImpl、WrapperConnectionPoolDataSource 等字符串,以及 autoType is not support、NotSerializableException 一类异常。
  5. 驻留核查:按上一节的四步法核对注册项、类文件落地情况、内存类清单与重启复现性,有条件的与干净基线做差分。
  6. 权限与出网核查:确认应用账户、容器根文件系统与出口策略符合最小权限与默认拒绝原则,查看是否存在异常外联与 DNS 解析失败记录。
  7. 处置与验证:入口与依赖修复后,用第 2 至第 4 项复查一遍,确认类型名不再被解析、危险依赖已被移除,并把本次的类清单与依赖清单作为下一次比对的新基线。

参考资料

该文对十六进制序列化字节加载器的原理有较完整的分析,对理解 userOverridesAsString 这一入口的成因有帮助。

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat