在Java代码审计的checklist里,反序列化不是“可选项”,而是每次必查的高危点。我在实际审计项目中,只要看到ObjectInputStream、readObject这类词,都会先停下来做一整套入口与依赖研判,因为这里一旦出问题,多数情况下不是普通的越权或信息泄漏,而是可以直接走到命令执行级别的RCE风险。读完这篇内容,你可以完整掌握Java反序列化底层协议、readObject在哪些隐蔽场景会被触发、gadget链如何拼装、源码里怎么快速定位入口,以及修复时为什么“黑名单”思路不靠谱。无论你是做代码审计的工程师、开发团队负责人,还是准备安全方向面试,这套流程都值得收一份。
1. 反序列化漏洞审计的思路与切入点
1.1 先搞清楚序列化到底在做什么
Java序列化与反序列化,可以简单理解成“存档”和“读档”。程序运行期间,对象全在内存里,进程一停,对象也就没了;序列化把对象的状态按二进制格式写下来,可以存文件、进缓存、走网络。另一方拿到字节流后通过反序列化,把对象状态恢复回来,继续使用。底层由ObjectOutputStream完成写入,ObjectInputStream完成读取。
表面看这个过程人畜无害,问题就出在Java为了让序列化机制灵活,允许每个类自己控制“如何恢复状态”。于是类可以重写readObject方法,在反序列化时执行额外的初始化逻辑,比如给缓存字段赋值、建立连接、校验数据等。当这条从“读字节”到“执行代码”的路径被外部攻击者控制时,漏洞就已经埋下了。
类比一下:序列化像快递打包,反序列化像拆包裹。Java允许包裹里有“说明书”,拆包裹的人必须按说明书操作,而攻击者完全可以伪造一份说明书,让收货方拆包裹时执行了自己并不想执行的步骤。问题不在于“拆包裹”本身,而在于“说明书”可以被人为构造,收货方又不验证说明书来源。
需要注意,这里涉及的不仅是直接new ObjectInputStream的场景。RPC框架、消息队列、分布式缓存、HTTP会话存储,甚至很多JSON处理库,在解析时都可能间接走到Java原生反序列化。所以审计思路不能只盯某个类名,而是要顺着数据流传。
1.2 反序列化漏洞分析的两个坐标系
在反序列化领域,普遍把问题拆成两个维度,第一个叫入口点,第二个叫gadget链。
入口点解决的是“数据能不能进到readObject”的问题。审计时要看:
- 谁在调用ObjectInputStream.readObject?
- 这个方法的InputStream从哪里来?
- 数据是否涉及不可信输入(外部HTTP请求、用户上传文件、MQ收到的消息、缓存中由外部可写的内容等)?
gadget链解决的是“进了readObject之后,能不能变成代码执行”的问题。触发要满足两个条件:一是项目依赖的jar包里,存在一系列类,它们的readObject、hashCode、compareTo、toString、getter等回调方法可以被串联;二是这条串联路径最终能导向危险操作,直白地说,就是能“想办法调用到Runtime.exec,或者触发JNDI远程类加载,或者通过TemplatesImpl加载恶意字节码”。
只有入口可控、链路可达,漏洞才真正成立。审计时不能因为项目里有readObject就简单定性,也不能因为依赖里存在旧组件就判断必然可利用,一定要把两个坐标系组合起来验证。
1.3 审计前要准备的工具与视角
代码审计反序列化漏洞,不需要太高端的工具,但准备工作做足了能省很多时间。
- 源码检索工具:IDEA的全工程搜索就够用,检索目标方法名,比如readObject、readUnshared、resolveClass、ObjectInputStream等。
- 依赖分析:看项目的pom.xml或构建脚本,列出所有第三方库版本,尤其关注Commons Collections、Commons Beanutils、Spring相关库等组件。
- 复现环境:本地搭建隔离的测试环境,先验证再写报告,避免对线上系统做任何直接测试。
- 辅助工具:业内常用的反序列化测试工具可以生成验证样本,也能帮助理解gadget链的调用关系。
视角上,审计者应该站在攻击者一侧想问题:如果我是攻击者,这个接口最方便传入什么样的序列化数据?这个依赖是不是能满足某个经典gadget链的前置条件?先想清楚攻击路径,再去代码里找证据,效率会高很多。
在正式看代码之前,还有必要把Java序列化协议的细节理一遍,因为这些细节决定了哪些隐晦入口容易被忽略。
2. 序列化协议底层运作机制
2.1 从二进制头看序列化
Java原生序列化输出有一定格式。前两个字节固定是十六进制的AC ED,是流标识;随后两个字节是版本号,常见为00 05。如果做网络抓包或查看存储文件,看到AC ED 00 05开头的二进制,基本就能断言是Java序列化对象。这个特征在审计分析里很有用,因为很多系统会把序列化结果Base64后放进HTTP参数或数据库字段,解码后如果看到这几个字节,就说明底层用的是原生序列化。
序列化流中通过标记协议来表示“这是一个新对象TC_OBJECT、这是类描述TC_CLASSDESC、这是字符串TC_STRING”等等,解析器会根据标记递归读取对象图。对象图里可能有循环引用,也有类名、字段名、字段值。
协议设计虽然历史比较久,但足以支撑复杂的对象序列化。问题在于这套协议从设计之初就没考虑“数据不可信”这个前提。它允许从序列化字节流中指定类名,并且让自定义readObject方法来处理数据。协议层面的“信任缺失”是今天所有反序列化漏洞的源头。
2.2 readObject的执行时机与自定义逻辑
类在实现java.io.Serializable后,可以自己定义readObject方法,签名一般是:
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 在这里做自定义恢复逻辑 }JVM反序列化这个对象时,不调用构造函数,而是直接分配内存、恢复字段,然后调用这个readObject方法。构造函数的访问控制和非空校验在反序列化时不会生效,这是审计中最容易看走眼的地方:一个类可能构造时限制很多,比如不允许某些字段为null,但反序列化完全绕过这些限制,直接恢复出“不合常规”但能继续执行逻辑的对象。
自定义readObject本身不一定是漏洞,但如果这个方法内部做了危险操作,或者它调用其他类的方法,而这些方法的参数又来自序列化数据,风险就上升了。审计时拿到一个readObject方法,先画它的调用树,确认传出的变量是否来自序列化数据,再判断会不会落到敏感操作上。
2.3 隐藏在集合类里的readObject触发点
真正的高价值入口点,往往不是某个业务类自己写的readObject,而是JDK自带的集合类在反序列化时,会对元素做额外操作。
HashMap.readObject在恢复时,会重新计算每个key的hash值并放入哈希表,而计算hash值就会调用key对象的hashCode()方法。PriorityQueue.readObject在恢复过程中,会调用比较器的compare方法对元素排序。TreeMap也是类似,会调用Comparator的compare。这意味着当反序列化字节流里的某个对象被放进集合时,即使没有任何业务代码主动调用它,集合内部的恢复逻辑也会“代为调用”该对象上的一些方法。
这些方法就成了gadget链的起点:HashMap的readObject触发某个类的hashCode,hashCode内部触发getter,getter内部触发transform,transform最终触发反射调用。从一个JDK自带集合类开始,可以在不依赖业务代码的前提下,丢入一个精心构造的对象,从而完整触发链路。
2.4 那些间接调用序列化的框架
因为直接使用ObjectInputStream太过暴露,框架通常会封装一层,但封装并没有改变本质。常见的几种“间接反序列化”:
- RMI远程调用:RMI的参数通过Java原生序列化传输,服务端反序列化参数。
- JMX:传递参数时也使用序列化。
- JMS消息队列:很多消息体直接就是序列化对象。
- Redis、Memcached等缓存中的Java对象序列化存储。
- HTTP Session:某些旧工程直接把序列化后的对象存入Session并随Cookie传递。
这些场景下,审计者一眼看不到ObjectInputStream,是因为框架内部把它封装了。因此判断依据要转向:数据是不是Java序列化格式?对象图内容是否由请求方控制?服务端在反序列化前有没有做类型过滤?
拿Cookie举例,如果会话管理读取的是“Base64解码后的字节,再反序列化”的对象,那就等价于公开了一个反序列化入口,任何能构造会话内容的攻击者都可以直接构造恶意对象。
3. Gadget链分析方法:从危险末端逆推到入口
3.1 常见的危险末端类别
gadget链的最终目的是把“方法调用”变成“危险操作”。从审计和防御角度,常用危险末端大致分几类:
- 命令执行:最终调用Runtime.getRuntime().exec,或ProcessBuilder。
- 动态类加载:通过TemplatesImpl的defineTransletClasses或类似机制,把字节码交给JVM加载并实例化。
- JNDI注入:对象完成一次lookup,加载远程引用对象或连接到恶意类服务器。
- JDBC连接:触发数据源相关组件的connect或connection方法,指向攻击者可控的地址。
以上任何一类出现,在入口可控的前提下都足以认定为高风险。
3.2 从TemplatesImpl看动态类加载
在Java类库中,TemplatesImpl是一个典型的类加载触发点。它原本用于XSLT模板,内部维护了一个字节码数组字段,在使用时会把字节码定义成类并实例化,从而加载出一种“translet”类。
安全研究者很早就发现,只要能让TemplatesImpl对象的getOutputProperties、newTransformer等方法被触发,就能让它把字节码数组加载成类,随后执行类的静态代码块。而TemplatesImpl本身没有重写readObject,但它可以作为链的一部分,被其他对象调用。
在审计过程中,当类路径里存在TemplatesImpl时,即使没有明显可用的危险对象集合,也值得标注关注。这类点在JDK内置的XML相关包中出现,升级业务依赖并不一定能移除它,因此多数修复方案要回到“不信任反序列化入口”上来。
3.3 调用型中间节点:transform如何链接
中间的gadget节点通常不是单个类,而是一串允许链式调用的对象。典型代表是Commons Collections库中一些用于“转换对象”的类:它们实现了Transformer接口,内部保存了“要调用的方法名”和“参数”,在调用transform方法时,通过反射完成指定方法调用。
一条常见链的形态可以这样理解:
- 集合类在readObject时触发元素的hashCode;
- hashCode方法内部调用了map.get;
- map.get触发某个工厂对象的transform;
- transform通过反射调用某个方法,比如执行命令的方法;
- 命令执行完成。
这不是哪个单独类的问题,而是“集合恢复逻辑 + 对象hashCode + 反射调用工具类”的组合问题。正因为链路由多个类拼成,审计修复时只修其中一个类并不解决整体问题,必须让入口侧不再允许任意类反序列化。
3.4 如何自己逆向分析一条链
安全研究员和审计者经常需要手工分析一条新链,步骤大致如下:
- 在依赖列表里找出所有带有readObject自定义方法的类,尤其是那些在恢复过程中调用了其他类方法的类。
- 画出调用图:哪个方法被调用,方法传入参数是否来自序列化字段。
- 找到危险末端后,从末端往回推,寻找什么方法能触发这个末端方法,例如某个类里有getter、hashCode、toString方法调用到了末端对象。
- 用反射写一个最小测试类,构造嵌套对象,走一遍流程,确认调用关系真实存在。
这套方法论和具体框架无关。实际项目里遇到没见过的组件,用同样思路也可以很快定位。
3.5 测试工具对审计的辅助意义
审计时常借用现成的反序列化测试工具(如某集合了多条gadget链的开源工具)来生成验证样本。它的价值不是盲目打payload,而是帮助识别“当前依赖版本能走到哪一步”。审计者可以在本地工程里加载相同依赖,生成不同样本看哪些链可以走通。复现成功后再回到源码里,把触发链和入口梳理清楚,写进报告,修复才有据可依。
另外要明确,工具生成的样本和测试代码只能在本地方可的环境里使用,用在真实系统上属于未授权操作,不仅没有意义,而且违法违规。审计过程应当始终以发现漏洞和推动修复为目标。
4. 源码审计中如何快速定位反序列化入口
4.1 特征方法检索清单
对代码库做第一遍特征扫描时,我会直接搜以下几组关键词,全部命中才说明代码库里确实存在反序列化相关逻辑:
- ObjectInputStream、readObject、readUnshared
- XmlDecoder、XStream、SnakeYAML、JSON反序列化相关接口
- resolveClass、findClass、Validator
- Serializable、Externalizable
- ObjectInput、ObjectOutput
搜索时不只看方法名本身,还要通过IDE的“查找引用”功能追踪对象从哪里构建,stream来自哪个数据源。常有代码把反序列化封装在框架里,业务代码里只剩一个消息DTO,这时要反过来查框架源码。
4.2 常见的入口模式与判定
比较典型的反序列化入口代码长这样:
public void processRequest(ServletRequest request) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); Object obj = ois.readObject(); // 后续类型强转并处理 }这段代码的问题非常明显:InputStream直接来自客户端,readObject后没有做任何类型约束。攻击者可以传入任意Java序列化对象,服务端会先做反序列化,再尝试类型转换。危险在于类型转换是放在反序列化之后的,恶意对象已经被还原、危险方法已经执行过了,而后面的转型失败不影响前面的攻击效果。
除了这种直白写法,还有几种包装形态:
- 先从HTTP参数读取Base64字符串,解码后交给ObjectInputStream;
- 从消息队列取byte[],直接反序列化;
- 把序列化字节从数据库字段中读出,交给框架处理;
- 在RPC传输层,服务提供方直接恢复请求体对象。
判定时只需问一个问题:这段字节的修改权限是否被不可信调用方掌握?如果答案是“是”,就把它标记为待验证入口。
4.3 别忘了JSON与XML这类“异型反序列化”
Java反序列化不止原生二进制一种形态。JSON、XML、YAML在反序列化时也可能还原对象并自动调用对象的指定方法。它们的共同特点是:输入内容里可以指定目标类的类名,框架根据类型名实例化对象。当项目中使用了这类框架且解析的是外部内容,同时类路径上又存在可利用组件,同样可以触发与原生反序列化相似的链。
审计这类代码时重点看两点:是否开启了“自动类型绑定”或“泛型未知时的类型推断”;是否配置了允许类列表。有的框架默认关闭危险特性,就相对安全;有的框架为了兼容性默认开得很宽,还没办法通过注解彻底关闭,那就只能从入口限制和依赖治理入手。
4.4 依赖版本排查与路径分析
在做定位的同时,也要同步排查依赖。先导出项目的依赖树,然后对照常见风险组件清单核对版本。拿Commons Collections举例,某些系列版本存在可以利用的TransformedMap、LazyMap等类,旧版本在后续版本里部分类被重新设计或移除。
依赖树分析只能给出“可能存在可利用类”,不能直接认定漏洞成立,因为攻击者还需要一条从序列化入口到触发点的链路,链路中间可能某个版本对类做了防护,也可能入口处已经做了类型白名单。捆绑验证的顺序建议:入口、依赖、类可用性、复现尝试、定性。
4.5 静态扫描与人工审计配合
常用的静态代码扫描器能标出readObject调用,但漏报很严重,尤其是依赖框架内部封装的方式。扫描器捕捉不到完整危险面,比如消息队列消费端把byte[]直接交给工具类处理。因此不要过度依赖扫描结果,扫描结果应当当作线索清单,人工分析才是定性的关键。审计者花时间最多的部分就是弄清数据流,而不是读“危险方法报告”。
5. 模拟项目审计实战:从入口到修复的全流程
5.1 项目背景说明
假设在一次授权的代码审计中,面对一个“某跨平台用户管理服务”的模拟项目:采用Spring Boot框架,提供HTTP接口,同时接入了消息队列,会话信息以Java序列化格式存储在节点共享缓存中。代码库里还有一个旧版commons-collections依赖。
这类背景在真实项目里经常碰到,只改了行业名称,架构模型完全通用。接下来整个分析过程,可以直接复制到实际项目中使用。
5.2 第一步:遍历入口
先对项目源码做全量搜索,方法包括中:
- 搜ObjectInputStream,出现五处,三处在工具类里,两处在MQ消息消费流程。
- 工具类是一个自定义序列化工具,对外提供toBytes和fromBytes方法。
- MQ消费流程调用fromBytes处理消息体。
继续追踪fromBytes的实现:
public Object fromBytes(byte[] data) throws IOException, ClassNotFoundException { ByteArrayInputStream bais = new ByteArrayInputStream(data); ObjectInputStream ois = new ObjectInputStream(bais); return ois.readObject(); }这段代码没有任何过滤机制。接着看谁调用了fromBytes:一个是内部定时任务,处理自己生成的数据;另一个是MQ消息监听器,处理从消息队列收到的byte[]。消息生产端存在多个业务模块,有一部分数据来自外部系统,因此消息体不可信。
到这里,入口就确定为MQ消息消费者。内部定时任务那份调用目前风险较低,但同样值得记录,因为如果后续其他模块也调用了这个工具并传入外部输入,风险会扩大。
5.3 第二步:确认数据源与依赖
查看MQ消费者监听器使用的topic,确认该topic是否完全由可信内部模块写入。如果只有信任的内部模块能写,风险降低,但很多人会忽略一种情况:消息队列本身如果存在未授权访问,或者另一个外部可写的topic也在消费同一个监听方法,入口就失控了。审计时一定要看清楚方法入参的topic以及生产端鉴权情况,不能凭经验推断。
依赖分析这一步比较顺利,项目引用了旧版commons-collections,其他常用jar包也齐全,没有看到依赖遮蔽之类的情况。同时确认JDK版本是8,符合经典链路可用的环境要求。
5.4 第三步:构造复现并验证
在本地环境创建复现工程,引入与项目一致的依赖版本。接着用反序列化测试工具,针对当前依赖组合生成若干条样本链。由于存在旧版Commons Collections和JDK8,预期至少会有两条链能触发。实际生成本地样本后,在测试工程里调用与fromBytes等价的反序列化方法,成功拿到命令执行回显,复现流程走通。
复现完成后,回到源码里抽查关键节点:确认readObject后业务类中确实存在将对象强转为“内部消息实体”的逻辑。这说明如果要做类型检查,本应在反序列化之前完成,而不是在反序列化之后。漏洞成立,评级高风险。
这个项目还附带发现第二个问题:会话存储里的对象通过同一个工具类处理,缓存读写过程可能被恶意数据污染。即使线上难以直接写入,横向渗透路径依然存在,验收修复时需要一并覆盖。
5.5 把发现整理成报告与修复建议
报告不能只写“存在反序列化漏洞”,要说明入口在哪个类哪个方法,数据流如何从头走到尾,依赖版本是什么,有哪些链在当前环境下可用,复现结果如何,修复优先级多高。更要紧的是给出明确修复方向,不能一刀切成“禁止使用反序列化”,因为项目里仍有内部对象传输需求。
修复建议通常包含三个层次:入口做类型白名单;将原生序列化格式更换成更可控的数据绑定格式,并对允许的类做显式配置;升级组件依赖。第6节我会展开讲每层的做法和取舍。
6. 修复与防御:不要只想着打补丁
6.1 为什么黑名单思路不可靠
最初不少团队用黑名单防御,比如拦截几个常见危险类,只要序列化字节流里不出现这些类名,就拒绝恢复。但这套做法问题很明显:黑名单永远更新不及时,只要攻击者找到一个没在黑名单里的类能达到同样效果,防御就失效,而Java类库庞大,没人能保证已经枚举全了。
很多防护方案实际落地时会叠加黑名单和白名单机制,但审计建议以白名单为根基。白名单是指:从字节流中恢复类时,只允许恢复明确的业务对象列表,其他类型一概拒绝。
6.2 用ObjectInputFilter实现白名单校验
在Java 9以后,可以借助ObjectInputFilter。在实际工程里,收到字节流后先创建过滤器,规定允许的类包前缀,然后绑定到ObjectInputStream上。
一个简化的实现思路如下:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.dto.*;java.lang.*;java.util.*;!*" ); ObjectInputStream ois = new ObjectInputStream(bais); ois.setObjectInputFilter(filter); Object obj = ois.readObject();最后这个“!*”是拒绝其他所有类的含义。规则顺序要注意,先放允许项,再放全局拒绝项,类校验才有意义。需要注意的是,白名单粒度不能太粗,写成java.util.*已经很危险,因为集合类本身就可能是触发链起点。更稳妥的做法是连集合元素类型一起限制,但标准ObjectInputFilter的规则没法直接限制元素深度类型,所以对复杂业务场景还是推荐使用框架自带的类型绑定方式。
6.3 重写resolveClass的兼容做法
如果是还在Java 6/7/8运行的项目,则通常用重写resolveClass的方式:
@Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name = desc.getName(); if (!name.startsWith("com.example.dto.")) { throw new InvalidClassException("Unexpected deserialized class", name); } return super.resolveClass(desc); }resolveClass会被序列化流中出现的每一个类描述所调用,所以顶层和嵌套类都会被检查。但有一个容易被忽略的地方:如果攻击者用的是已允许类构造的链式节点,而该节点内部又会自动加载别的类,仅靠resolveClass可能拦不住后面的加载行为。因此,白名单方式比黑名单好,但也不是万能药,仍要配合入口不可信数据最小化处理。
6.4 升级依赖与移除无效依赖
依赖治理和入口过滤并不冲突,两者都要做。具体动作:
- 将commons-collections等老库升级到不再包含可利用类的版本,并验证业务代码没有引用被移除的API。
- 对项目中没被使用的风险组件,直接移除。
- 同时排查传递依赖,有些组件自己也会带上旧版集合库,形成“你改了顶层,漏洞还在传递依赖里”的局面。
- 升级后再跑一遍反序列化测试工具,确认当前组合已经无法生成可用测试链。
升级依赖的效果是“让链在类路径上找不到中间节点”,但类路径上永远有JDK自带的一些类组合可能形成新链,所以依赖升级不能替代入口白名单。
6.5 数据格式改造:尽量消除原生序列化
如果业务允许,更彻底的做法是改造数据传输层。比如:
- 把对象转成JSON,利用类型绑定解析到特定DTO,再手动字段映射;
- 使用那些严格按类型白名单解析的协议;
- 如果是内部RPC,不要直接传输序列化字节,要求走IDL定义好的消息结构。
改造后,原来的ObjectInputStream入口只在极少数的可信内部场景保留,并且也要绑定白名单。工程上这种改造往往涉及多个服务联调,周期长。建议分两步:先在入口加过滤,让风险可控;再排期做格式迁移,把依赖原生序列化的面缩到最小。
6.6 纵深防御与监控
即使做了以上修复,工程上仍建议补充旁路手段:
- 对反序列化入口做日志记录,记录请求来源、字节流大小、解析类名,类名列表异常时能早期预警。
- 网络侧限制外部系统到MQ、缓存、RPC端口的访问来源。
- 站在运维视角,对反序列化热点组件做外联限制,禁止运行时向外部地址发起回连请求。某些回连攻击依赖从本地服务器向外部LDAP服务器发起连接,如果能限制出站流量,即使链被触发,影响面也会受限。
- 定期把最新依赖列表与已知风险组件库做比对,及时发现版本过期。
纵深防御的意义是:单点修复一旦被绕过,其他机制还能兜底,不至于直接落到命令执行。
7. 实战中的常见问题与排查手册
7.1 本地复现时样本链无效
最多见的问题是:测试工具生成的样本依赖特定版本Commons Collections的类路径,而审计项目里虽然有同名依赖,版本却不一样。类名可能相同,但方法行为或字段名已变化,导致链路上某一环断掉。排查方法很直接:在复现工程里用一致的依赖清单,推荐直接用项目的jar包导出后作为复现工程依赖,不要手工挑版本。
另一种常见问题是JDK版本不一致,某些JDK内置类或内部包的实现差异,直接影响TemplatesImpl相关链的运行。复现环境的JDK版本必须和审计目标一致。
7.2 serialVersionUID不匹配报错
反序列化时如果类的UID不一致,会抛出InvalidClassException,报错信息里会给出本地类和流中类的UID。这意味着字节流对应的类与当前类路径上的类版本不同。审计看到这个异常时,不代表漏洞不存在,只说明样本用的旧类结构和当前类不符。处理思路是更新样本生成端的依赖到目标项目版本,或调整本地的序列化兼容配置。
7.3 扫描器报了反序列化,人工却看不出风险
静态扫描经常把工具类里一个通用的fromBytes方法报成风险,但它可能只被内部可信任务使用。这类误报靠“数据流可控性”来过滤:追踪调用链,确认传入字节流是否可能来自网络、外部消息等边界。判断标准就是:谁能写这段字节流。写权限在自家程序里,风险低;写权限在外人手里,风险高。
7.4 JSON反序列化组件查不出原生readObject
这类现象最坑。业务代码里没有ObjectInputStream,但JSON框架解析外部内容时,实际上在不受控地实例化任意类型。排查时可搜索反序列化框架的配置,确认是否开启了自动类型绑定、泛型未知时的类型推断等动作。若能间接指定任意类名,风险与原生反序列化等价。
7.5 修补后如何回归验证
改动后不能只看“代码里没有readObject了”就当修复完成。建议走完整的验证动作:
- 在本地复现环境重新生成攻击样本,确认样本无法触发命令执行;
- 编写允许类白名单的单元测试,确保业务对象还能正常收发;
- 对MQ消费者和缓存读写两条链路各跑一轮功能回归;
- 最后把异常类名日志的监控打开,上线后观察一段时间。
这些动作看似繁琐,却能有效防止“改了但没改对”的情况。
7.6 一个值得记录的审计习惯
我在做这类审计时,习惯用表格记录每个入口的关键信息:入口位置、数据源、是否可控、当前过滤状态、受影响依赖、验证结论。字段很简单,但它对审计结论的可靠性和后续复测的帮助极大。同一个项目隔半年再做增量审计时,直接看表格就能知道哪些入口已经处理,哪些还没收尾。
抛开理论不谈,我个人在项目里反复体会最深的一点是:反序列化漏洞从来不是单独某个类或某行代码的锅,而是“数据入口、依赖、JDK内部机制”这三者互相作用的结果。审计时如果只盯着危险方法名,很容易漏掉真正入口;只盯着入口,又容易被依赖版本带偏。把两条线真正拉通,本地复现一遍,再谈修复,流程才算闭环。希望你下一次面对readObject时,能做到心中有链,手上有据。