ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java反序列化审计实战:报表平台channel模块hsql链拆解与加固

Java反序列化审计实战:报表平台channel模块hsql链拆解与加固

把某报表平台的下行通道(channel)模块纳入代码审计范围时,反序列化相关的问题总是绕不开。第一次接触这类漏洞的同学,很容易把它当成“某个框架自带的坑”,但真正把调用链捋完之后会发现,问题往往出在业务代码对序列化数据过度信任,以及依赖组件中存在一套可以被任意拼接的“可序列化危险零件”。这篇文章会从审计者的视角,把某报表平台channel入口暴露出来的hsql链拆开讲清楚:先卡住触发面,再拆调用链,最后讨论怎么加固。适合负责Java服务端安全、企业级报表应用开发和运维的同学阅读,也适合想把代码审计当切入点的入门者当作一条实践路径。

1. 审计前的整体思路:从系统边界到序列化攻击面

1.1 为什么反序列化问题总是出现在传输环节

Java原生序列化解决的是“对象怎么保存、怎么传”的问题。一个Java对象经过ObjectOutputStream变成字节流,另一端用ObjectInputStream读回来,中间可以走文件、缓存、消息队列,也可以直接塞进HTTP请求体。这套机制本意是方便开发,但它的一个关键特性是:反序列化时会自动恢复对象的完整状态,并在特定条件下触发对象内部的某些方法。

我习惯把反序列化比作“按说明书自动组装机器人”。攻击者不关心说明书具体写了什么,只关心能不能把说明书里的零件偷偷换成另一批零件。只要接收方没有对“零件类型”做严格限制,组装出来的机器人就可能做出超出预期的事。很多Java服务端的反序列化漏洞,本质都是同一个问题:接收方对输入流的类信任得太彻底了。

企业在实际架构里会大量使用序列化。Session共享、RPC调用、MQ消息体、缓存对象,任何一个环节只要暴露了可被外部触达的流量入口,就可能变成攻击面。报表平台这类企业级应用尤其明显,因为它通常要做数据接入、定时推送、跨系统数据交换,传输链路非常多,只要有一条链路用了原生序列化且没做类过滤,整条链路都可能被当成突破口。

1.2 先识别核心攻击面:channel模块的特别之处

某报表平台里有一个名为channel的下行模块,听名字像是“通道”,实际承担的是外部数据接入与下行推送。这个模块的特殊性在于两点:

第一,它面向的是多类数据源。既接收上游业务系统推过来的结构化数据,也把平台内部的对象快照下发到各个终端节点。为了便于跨系统传对象,开发人员很自然会选择Java序列化。

第二,它的访问边界比较模糊。我审计的那个版本里,channel模块的相关入口并不都在管理端页面后面,一些接口可以被普通业务用户触发。这类接口一旦被用来传递序列化对象,就直接把“外部输入”和“服务端对象恢复”连在一起了。

从攻击者的角度看,channel模块提供了很好的调用前置条件:可控输入、服务端自动反序列化、依赖中携带可利用的第三方库。所以这个入口不是凭空被盯上的,而是整个系统里“传输对象最频繁、校验最少”的地方。

1.3 用“三张清单”收敛审计范围

面对一个大型系统,我不会一上来就翻漏洞库或者全局搜索关键字。那太慢了,而且容易漏。我一般会先整理三张清单:

  • 入口清单:所有会调用ObjectInputStream.readObject的地方,包括直接调用和间接调用。搜索方式很简单,全局搜readObject、ObjectInput、XMLDecoder这些关键词,然后把对应的Servlet、Controller、RPC方法名记下来。
  • 依赖清单:当前运行环境的第三方库及其版本,重点看是否存在已知的“可序列化危险类”。这一步可以借助依赖扫描工具配合人工判断。
  • 数据流清单:从外部输入到readObject之间,有没有经过任何过滤、黑名单、白名单、加密、压缩、解码。如果中间有一层Base64或GZIP解压,攻击者依然可以构造字节流,这不算有效防护。

三张清单列完之后,攻击面基本就清晰了。我当时在channel模块对应的依赖清单里,很快就发现了一个非常可疑的组合:底层存在HyperSQL相关的JDBC驱动类,而上层业务代码在读取下行对象流时没有使用任何类过滤机制。这个组合让我决定重点验证hsql链。

2. 核心细节解析:hsql链是怎么串起来的

2.1 readObject与“调用链拼接”的本质

Java反序列化漏洞之所以复杂,是因为攻击者并不直接写“我要执行命令”这种代码,而是利用一组组可序列化类的方法调用关系,拼出一条从readObject出发、最终落到危险方法上的调用链。

举个例子:某个类的readObject方法在被调用时,会读取一段属性,然后调用该属性的某个getter或setter。攻击者把属性对应的类型替换成另一个可序列化类,这个类的方法内部又去调用了别的对象的方法。层层包装之后,最初看起来“只是恢复了一个普通对象”的反序列化过程,实际执行的却是一条精心编排的Java调用序列。

这就是代码审计里常说的“链”。链的起点是readObject,终点是能造成系统性风险的危险方法,比如远程类加载、命令执行、文件写入、数据库连接等。hsql链属于其中比较典型的一条,它本质上没有用到多高深的技术,而是把Java多态和反射拼接发挥到了极致。

2.2 一个可控支点:JdbcDataSource与远程解析

hsql链之所以值得单独分析,是因为它不需要依赖某个高危Action类,而是利用了HyperSQL自带的JdbcDataSource这个“中立类型”。

JdbcDataSource是数据库连接池中很常见的一个实现类,实现了javax.sql.DataSource接口,并且可以被序列化。它内部保存了数据库连接的URL、用户名、密码等属性。正常开发中,这类对象用于建立数据库连接,没人会觉得一个数据库连接池类有什么问题。

但关键在于它的getConnection方法。当某个对象的readObject流程被攻击者编排后,一旦执行到“调用某个DataSource的getConnection”这一步,JdbcDataSource就会根据内部保存的属性发起一次远程连接解析。攻击者可以控制这个属性值,把它指向一个自己准备的外部解析服务。这样一来,只要链上任何一环调用了getConnection,就会把原本的“反序列化对象恢复过程”变成“可控的远程资源获取过程”。

理解这一点后,再看hsql链就很清晰了:它不是在某个类里藏着恶意代码,而是把一个功能完全正常的数据库连接类当作“支点”,通过外部连接属性把执行流引入到攻击者控制的资源上。这种利用方式非常典型,也是为什么很多黑名单机制对hsql链失效的根本原因——你不能随随便便把数据库连接池类拉黑,那会影响正常业务。

2.3 为什么黑名单拦不住:类过滤的盲区

很多早期反序列化防护都采用黑名单思路,比如Jdk7u21、Jdk8u20这些补丁中,Oracle就不断把已知的危险类添加进过滤清单。黑名单的优点是实现简单,缺点也非常明显:只能拦截“已经出场过的类”。

hsql链里的核心组件是HyperSQL自带的JdbcDataSource,属于第三方库中的正常类,并不在Java原生黑名单中。如果系统只过滤了CommonsCollections、TemplatesImpl这些“老明星”,却对HyperSQL组件网开一面,那这条链就能一路通畅地走到底。

更麻烦的是,即便把JdbcDataSource也加入黑名单,攻击者还可以找其他具备同等效果的可序列化类,比如其它数据库驱动类。这就是一个“打地鼠”的过程:你永远不知道下一个能用做支点的类藏在哪个依赖里。所以我在审计时不会建议只依赖黑名单,而是应该考虑从入口层面控制所有反序列化数据类型。

3. 实操过程与核心环节实现:审计与验证实录

3.1 环境准备与依赖版本确认

开始验证之前,我先把运行环境固定下来。步骤如下:

第一,确认JDK版本。不同JDK对反序列化过滤器的支持差异很大,JDK 9之后才有ObjectInputFilter,JDK 8u121之后才开始有相关的安全增强。如果线上环境是JDK 8的老版本,很多加固手段根本用不上。

第二,确认HyperSQL组件版本。在依赖清单中,重点看引用的hsqldb是哪个版本,老版本和新版本的类结构有差异,直接影响调用链是否走得通。

第三,准备一个隔离的测试工程。我建议用模拟项目X来做这件事,不要在真实生产环境里试错。测试工程只需要引入对应版本的依赖,再写一个最基础的序列化和反序列化入口,不需要把整个报表平台跑起来。

模拟项目X里我做了这样一个模拟:把某个对象序列化后,经过Base64编码,再通过自定义反序列化入口还原。这个入口故意不做任何类过滤,用来模拟channel模块里那种“直接恢复对象”的场景。

3.2 在模拟项目X中复现触发链路

复现过程不是直接执行现成工具,而是把调用链的每一环看清楚。我习惯先在测试工程里写一个独立的类,用来验证JdbcDataSource是否被正常序列化和反序列化,以及它保存的属性如何被恢复。

关键验证点在反序列化后的对象上。通过反射拿到恢复出来的实例,再调用它的getConnection方法,观察是否出现了预期中的远程解析动作。这一步能确认两件事:一是当前JDK版本下,JdbcDataSource的序列化行为是否符合预期;二是远程解析的触发条件是否与配置的属性强相关。

验证通过后,我再把“调用getConnection”这个动作挂到一个readObject可触发的链路上。重点观察链路执行到哪一层会调出外部解析。整个过程用到的是Linux环境下的网络抓包配合Java日志,一旦发现运行日志中出现了指向外部地址的连接记录,就说明链路的最后一段已经被打通了。

我在模拟时发现的细微难点是:不同版本对URL属性的解析方式有差异,有的版本会做额外的校验,导致连接请求发不出去。所以环境版本一定要和线上保持一致,否则你测出来的结果可能和线上完全相反。

3.3 观察日志与断点定位

反序列化调用链里最让人头疼的问题是“它看起来没反应”。因为在链路上,开发者往往没有留下任何显式日志,异常也容易被外层吞掉。

我的做法是分两层观察:

第一层,在反序列化入口的ObjectInputStream.readObject处打上断点,逐步跟踪调用栈。如果断点被触发,并且栈里出现了与远程解析相关的类,说明调用链已经走到了危险区。

第二层,在网络层观察外联。反序列化触发远程解析时,如果测试环境没有真实的外部解析服务,连接请求会失败,但失败之前通常会有连接尝试记录。用tcpdump抓包,很容易在日志里看到目标IP和端口。这里有个小技巧:把域名指向一个本地回环地址,如果反序列化触发了解析,本地监听端口就会收到连接,这种验证方式比看日志更直观。

实战里我遇到过判断失误的情况:一开始以为链路完整,结果发现真正触发的原因只是旧类版本中的某个Getter方法在不经意间调用了日志记录器,和远程解析压根没关系。所以观察日志时不能只看“有没有异常”,一定要结合调用栈来看“是谁在什么时机发起了这个动作”。

3.4 与修复版本对比找补丁

拿到官方修复版本后,我最常做的事是用diff工具对比受影响类的变化。这比看补丁公告更直接。

在某个修复版本里,我看到channel模块的反序列化入口中增加了ObjectInputFilter,同时把HyperSQL组件从主链路依赖中剥离到了独立可选模块。这两处改动说明官方也认同“外部输入不应直接进入原生序列化恢复”的思路。对比版本变化时,除了看代码差异,还要注意依赖描述文件的变化。有些组件虽然代码没改,但依赖范围发生了调整,从compile变成了runtime或provided,这也会影响攻击者的利用条件。

把补丁变化记录在审计报告中很重要。后续复查时,可以据此快速判断线上版本是否仍然存在同类风险。

4. 常见问题与排查技巧实录

4.1 排查速查表

我把反序列化审计中常见的现象整理成一张速查表,方便后续同类问题快速定位:

现象可能原因处理建议
本地能触发,测试环境不触发JDK版本差异、依赖版本不一致、外部解析服务不可达核对环境差异,统一依赖版本后再次验证
反序列化过程无任何日志异常被外层框架吞掉、日志级别过低在readObject入口加入临时调试日志或断点
触发后没有外联记录链路未真正走通、类过滤或网络策略拦截用tcpdump监听回环端口,检查调用栈末端
修复readObject入口后扫描仍报漏洞其他链路也存在同类入口重新梳理全量入口清单,逐一加过滤
升级组件后业务功能异常组件API变化、依赖范围调整在测试环境做兼容性回归,重点验证核心调用方

4.2 本地能触发但测试环境不触发

这类问题出过太多次了。属性配置一样,本地一条链路走得很顺,测试环境却静悄悄。

第一反应应该是检查外部解析服务是否可达。反序列化链路的最后一步需要向攻击者控制的地址发起连接,但如果测试环境的防火墙拦截了这类外联,连接会在超时后被丢弃,链路自然就断了。

第二个常见原因是JDK版本差异。不同JDK版本对JNDI远程解析的限制不同,JDK 8的某个小版本之后开始有trustURLCodebase等限制,直接把远程加载关了。所以我在做审计复现时,会特地在测试环境跑几组不同JDK版本的用例,看看哪条链路在哪个版本下是真正可行的。线上版本以最低配版本的验证结果为准。

还有一个容易被忽略的是依赖版本。项目里如果用Maven或Gradle做依赖仲裁,最终运行时的类可能不是你在POM里写的那个版本。排查时用mvn dependency:tree看完整依赖树,确认HyperSQL组件实际生效的版本。

4.3 反序列化异常被吞掉,根本不知道发生了什么

不少Java框架在try/catch时习惯性把异常吞掉,只打一行“IOException during read”之类的日志就完事了。这种习惯在反序列化攻击面前特别危险,因为攻击者构造的类服务器端根本不认识,异常会被迅速捕获,你连一条有价值的Stack Trace都留不下来。

我的建议是审计期间临时开启更细的日志级别,或者直接在看门狗位置打印完整的异常栈。同时在网络层保留抓包记录,一旦发现异常流量,就能和调用链对应起来。线上环境不建议长期开全量调试日志,但至少要保证反序列化入口处的异常不被吞掉。

4.4 修复入口后扫描还是报高危

这个问题往往让人很困惑:明明已经在某个controllers里加了ObjectInputFilter,为什么漏洞扫描器还是报高危。

原因是报表平台这类应用里,反序列化入口远不止一个。入口可能同时存在于HTTP接口、消息队列监听器、RPC回调、定时任务调度器里。你修复了A入口,B入口和C入口依然是透明的。

我建议做一个“入口清点专项”:全项目搜索ObjectInputStream、readObject、XMLDecoder、ObjectInput等关键词,把搜出来的每一条记录都过一遍,确认是否已经加过滤。扫描器之所以叫“扫描器”,就是因为它会遍历所有路径,不会因为你在某个入口打了补丁就放过其他入口。

5. 加固建议:踩过坑之后的三种止损方式

5.1 第一步封入口:拒绝原生序列化对象进业务接口

如果成本允许,最彻底的做法是把channel模块的对外接口从“Java原生序列化”切换到JSON或字符串协议。

很多业务方坚持用原生序列化,是因为传递对象方便。但反过来想,报表平台的下行通道本质上是在传结构化数据,JSON表达能力完全够用,而且JSON解析不会自动触发对象内的任意代码逻辑。切换协议后,攻击者即使能构造恶意字节流,也无法被接口解析,hsql链这种利用方式等于直接从根上被断掉。

我理解有些老系统切换协议成本高,那么至少要做到:新接入的接口一律不用Java原生序列化,存量接口排期改造。改造期间,临时用过滤器兜底,兜底期越短越好。

5.2 第二步加过滤器:ObjectInputFilter落地细节

无法立刻切换协议的存量接口,需要尽快加ObjectInputFilter。这个机制可以限制反序列化允许的类型,属于白名单思路。

落地时要注意三点:

  • 过滤器必须放在所有readObject入口,不要只放几个看起来“危险”的Servlet上。入口的分布情况必须由前面的“入口清单”来保证。
  • 过滤规则要允许业务自身的数据模型类,同时拒绝可疑的通用危险类。判断标准看类名和包名,不要轻易放行第三方库中的任意基类。
  • 过滤器需要支持可配置。随着依赖版本升级,新依赖可能引入新的合法类,如果规则写死,上线后会出现大量反序列化失败,这时开发会被迫关闭过滤器,安全防线就破了。

我在测试环境里会专门准备一组“绕过样例集”,每增加一条新规则就跑一遍样例集,看哪些路径还能穿过过滤器。过滤器不可能做到绝对安全,但它能把可利用的类范围缩得足够小。

5.3 第三步升级与权限控制

升级依赖的作用不是消除漏洞,而是提高利用成本。HyperSQL组件如果不再被src主链路直接依赖,攻击者需要先把组件“引入”运行时环境,这比直接在依赖里白捡一个支点麻烦得多。

同时要治理权限。channel模块的接口如果只能由特定权限角色触发,外部普通用户就无法直接构造成反序列化请求。权限控制属于纵深防御的一部分,不能指望它单独挡住攻击,但它能显著降低攻击面暴露概率。

5.4 给开发团队的三条红线

结合这次审计,我给团队定了三条红线,后续编码规范里直接写进去了:

  • 外部输入不允许直接用内置序列化类还原对象。所有对外接口接收的参数必须是字符串、字节数组等基础类型,由业务层手工解析成对象。
  • 不允许新增对高风险组件的直接依赖。如果必须使用数据库连接池类,就应该明确使用场景,禁止在可序列化消息体里放置连接池对象。
  • 所有反序列化入口必须默认加过滤器,并且过滤器规则变更要走安全评审。安全评审要重点看规则的放宽范围,防止为了业务方便把一个高危类白名单化。

这三条红线不一定适用于所有项目,但在我审过的报表平台里,它们是直接能堵住hsql链这类问题的关键。红线不是用来束缚开发的,而是用来防止有人在不了解风险的情况下,给攻击者铺好一条直通内网的路。

我在那次审计中最深的体会是:反序列化漏洞的排查一半靠技术,一半靠耐心。技术上要在调用链里慢慢走,耐心上要接受“看起来一切正常”的表象,直到最后一层网络连接才露出马脚。如果你也想上手练一练,建议从一个小项目开始,先把它所有readObject入口都列出来,再逐个核对依赖,你会发现真正危险的并不是某个库本身,而是它出现的位置恰好在一个信任了所有输入的边界上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进