
把某报表平台的下行通道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之后才有ObjectInputFilterJDK 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入口都列出来再逐个核对依赖你会发现真正危险的并不是某个库本身而是它出现的位置恰好在一个信任了所有输入的边界上。