Java Record 反序列化——从机制差异到攻防
漏洞分析
Java 原生反序列化(java.io.ObjectInputStream)一直是安全领域的重灾区。从 2015 年 Apache Commons Collections 反序列化漏洞被公开,到 2021 年 Log4Shell 中 JNDI 注入与反序列化的联动利用,Java 反序列化攻击已经形成了成熟的方法论体系。
一、引言 ---- 传统 POJO 的反序列化过程绕过了构造函数,使用 `sun.misc.Unsafe` 直接分配未初始化对象,随后通过反射写入字段值。开发者在构造函数中编写的校验逻辑,在反序列化场景下形同虚设。 JDK 16(JEP 395)正式引入了 Record 类。Record 的反序列化机制发生了根本性变化——必须通过规范构造函数(Canonical Constructor)创建实例。这一改变是否真正封堵了反序列化攻击?是否仍存在可利用的攻击面? 二、实验环境 ------ ### 2.1 环境信息 | | | |---|---| | 组件 | 版本 | | 操作系统 | Kali Linux 2026.1 | | JDK | OpenJDK 21.0.11+10 | | 构建工具 | Apache Maven 3.9.9 | | 依赖库 | commons-collections 3.2.2(用于 Gadget 链验证) | ### 2.2 环境安装 安装 JDK 21 ```php sudo apt-get update && sudo apt-get install -y openjdk-21-jdk maven java -version ```  ```php mvn -version ```  ### 2.3 项目结构  ### 2.4 pom.xml ```php <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.seclab</groupId> <artifactId>record-deser-lab</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.2</version> </dependency> </dependencies> </project> ``` ### 2.5 编译项目 ```php cd record-deser-lab mvn compile ```  三、传统 POJO 与 Record 反序列化的本质差异 ---------------------------- ### 3.1 传统 POJO 反序列化流程 `ObjectInputStream` 对普通 `Serializable` 类的反序列化流程如下:  `Unsafe.allocateInstance()` 创建的对象从未经过构造函数,任何写在构造函数中的参数校验、不变量约束都被完全绕过。这意味着攻击者可以构造出开发者认为"不可能存在"的非法对象状态。 ### 3.2 Record 反序列化流程 `ObjectInputStream` 通过 `Class.isRecord()` 检测到目标类为 Record 类型后,进入完全不同的代码路径。以下流程基于 JDK 21 源码(`ObjectInputStream.java` 第 2350-2400 行附近的 `readRecord()` 方法):  Record 不使用 `Unsafe`,而是通过 `MethodHandle` 调用规范构造函数。构造函数中的所有校验逻辑都会被执行。如果校验不通过,构造函数抛出的异常会被包装为 `InvalidObjectException` 向上传播,反序列化失败。 四、实验一:构造函数绕过对比 -------------- > 当序列化字节流被篡改为非法值时,POJO 和 Record 的反序列化行为有何区别? ### 4.1 序列化工具类 SerializationUtils.java ```php package com.seclab.util; import java.io.*; public class SerializationUtils { public static byte[] serialize(Object obj) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { oos.writeObject(obj); } return baos.toByteArray(); } public static Object deserialize(byte[] data) throws IOException, ClassNotFoundException { ByteArrayInputStream bais = new ByteArrayInputStream(data); try (ObjectInputStream ois = new ObjectInputStream(bais)) { return ois.readObject(); } } /** * 带反序列化过滤器的反序列化方法 */ public static Object deserialize(byte[] data, ObjectInputFilter filter) throws IOException, ClassNotFoundException { ByteArrayInputStream bais = new ByteArrayInputStream(data); try (ObjectInputStream ois = new ObjectInputStream(bais)) { ois.setObjectInputFilter(filter); return ois.readObject(); } } /** * 将字节数组转为十六进制字符串,每行 16 字节,用于调试和分析字节流结构 */ public static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < bytes.length; i++) { if (i > 0 && i % 16 == 0) sb.append("\n"); sb.append(String.format("%02X ", bytes[i])); } return sb.toString(); } } ``` ### 4.2 传统 POJO 定义 PojoUser.java ```php package com.seclab.basic; import java.io.*; public class PojoUser implements Serializable { @Serial private static final long serialVersionUID = 1L; private String username; private int age; public PojoUser(String username, int age) { // 构造函数中的校验逻辑 if (username == null || username.isBlank()) { throw new IllegalArgumentException("用户名不能为空"); } if (age < 0 || age > 200) { throw new IllegalArgumentException("年龄必须在 0-200 之间,当前值: " + age); } this.username = username; this.age = age; } public String getUsername() { return username; } public int getAge() { return age; } @Override public String toString() { return "PojoUser{username='" + username + "', age=" + age + "}"; } } ``` ### 4.3 Record 类定义 RecordUser.java ```php package com.seclab.basic; import java.io.*; public record RecordUser(String username, int age) implements Serializable { // 紧凑规范构造函数(Compact Canonical Constructor) // 编译器会在该代码块之后自动补充 this.username = username; this.age = age; public RecordUser { if (username == null || username.isBlank()) { throw new IllegalArgumentException("用户名不能为空"); } if (age < 0 || age > 200) { throw new IllegalArgumentException("年龄必须在 0-200 之间,当前值: " + age); } } } ``` ### 4.4 readObject 测试辅助类 为了验证 Record 是否忽略 `readObject()` 方法,分别定义含有 `readObject()` 的 POJO 和 Record: PojoWithReadObject.java ```php package com.seclab.basic; import java.io.*; /** * 含有 readObject() 的 POJO——用于对比验证 */ public class PojoWithReadObject implements Serializable { @Serial private static final long serialVersionUID = 1L; private String data; public PojoWithReadObject(String data) { this.data = data; } @Serial private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); System.out.println(" [!] PojoWithReadObject.readObject() 被调用,data=" + data); } @Override public String toString() { return "PojoWithReadObject{data='" + data + "'}"; } } ``` RecordWithReadObject.java ```php package com.seclab.basic; import java.io.*; /** * 含有 readObject() 的 Record——验证该方法是否被 ObjectInputStream 忽略。 * 此方法可以编译通过,但 ObjectInputStream 不会调用它。 */ public record RecordWithReadObject(String data) implements Serializable { @Serial private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { throw new RuntimeException("readObject() 被调用了!这不应该发生!"); } } ``` ### 4.5 对比实验主类 BasicCompareTest.java ```php package com.seclab.basic; import com.seclab.util.SerializationUtils; import java.io.*; public class BasicCompareTest { public static void main(String[] args) throws Exception { System.out.println("====== Java Record 反序列化安全特性对比实验 ======\n"); // --- 实验 1: 正常序列化/反序列化 --- System.out.println("[实验 1] 正常数据的序列化与反序列化"); PojoUser normalPojo = new PojoUser("alice", 25); byte[] pojoData = SerializationUtils.serialize(normalPojo); System.out.println(" POJO 序列化数据长度: " + pojoData.length + " bytes"); PojoUser deserializedPojo = (PojoUser) SerializationUtils.deserialize(pojoData); System.out.println(" POJO 反序列化结果: " + deserializedPojo); RecordUser normalRecord = new RecordUser("alice", 25); byte[] recordData = SerializationUtils.serialize(normalRecord); System.out.println(" Record 序列化数据长度: " + recordData.length + " bytes"); RecordUser deserializedRecord = (RecordUser) SerializationUtils.deserialize(recordData); System.out.println(" Record 反序列化结果: " + deserializedRecord); // --- 实验 2: 打印序列化字节流,分析结构 --- System.out.println("\n[实验 2] 序列化字节流十六进制对比"); System.out.println(" POJO 字节流:"); System.out.println(" " + SerializationUtils.bytesToHex(pojoData).replace("\n", "\n ")); System.out.println(" Record 字节流:"); System.out.println(" " + SerializationUtils.bytesToHex(recordData).replace("\n", "\n ")); // --- 实验 3: 篡改序列化字节流中的 age 值为 -1 --- System.out.println("\n[实验 3] 篡改字节流:将 age 值修改为 -1(非法值)"); // 定位 age 字段在字节流中的偏移量 int pojoOffset = findIntInBytes(pojoData, 25); int recordOffset = findIntInBytes(recordData, 25); System.out.println(" POJO 字节流中 age=25 的偏移量: 0x" + Integer.toHexString(pojoOffset)); System.out.println(" Record 字节流中 age=25 的偏移量: 0x" + Integer.toHexString(recordOffset)); // 篡改 POJO 字节流 byte[] tamperedPojoData = tamperIntAt(pojoData, pojoOffset, -1); try { PojoUser tamperedPojo = (PojoUser) SerializationUtils.deserialize(tamperedPojoData); System.out.println(" POJO 篡改后反序列化: " + tamperedPojo); System.out.println(" >>> 结论: 构造函数校验被绕过!非法对象被成功创建 <<<"); } catch (Exception e) { System.out.println(" POJO 篡改后反序列化失败: " + e.getMessage()); } // 篡改 Record 字节流 byte[] tamperedRecordData = tamperIntAt(recordData, recordOffset, -1); try { RecordUser tamperedRecord = (RecordUser) SerializationUtils.deserialize(tamperedRecordData); System.out.println(" Record 篡改后反序列化: " + tamperedRecord); } catch (Exception e) { System.out.println(" Record 篡改后反序列化失败: " + e.getClass().getName()); Throwable cause = e.getCause(); System.out.println(" 异常信息: " + (cause != null ? cause.getMessage() : e.getMessage())); System.out.println(" >>> 结论: 构造函数校验生效!非法对象被拦截 <<<"); } // --- 实验 4: 验证 readObject() 是否被调用 --- System.out.println("\n[实验 4] 验证 POJO 与 Record 的 readObject() 调用情况"); testReadObjectInvocation(); } /** * 从末尾向前搜索字节流中 int 值的偏移量(大端序 4 字节)。 * 从末尾搜索可以跳过类描述符等元数据区域,优先匹配字段数据。 */ private static int findIntInBytes(byte[] data, int value) { byte b0 = (byte) (value >>> 24); byte b1 = (byte) (value >>> 16); byte b2 = (byte) (value >>> 8); byte b3 = (byte) value; for (int i = data.length - 4; i >= 0; i--) { if (data[i] == b0 && data[i + 1] == b1 && data[i + 2] == b2 && data[i + 3] == b3) { return i; } } return -1; } /** * 在指定偏移量处替换 4 字节 int 值(大端序) */ private static byte[] tamperIntAt(byte[] data, int offset, int newValue) { byte[] result = data.clone(); result[offset] = (byte) (newValue >>> 24); result[offset + 1] = (byte) (newValue >>> 16); result[offset + 2] = (byte) (newValue >>> 8); result[offset + 3] = (byte) newValue; return result; } /** * 分别使用含 readObject 的 POJO 和 Record 进行反序列化测试 */ private static void testReadObjectInvocation() throws Exception { // 测试 POJO 的 readObject PojoWithReadObject pojoObj = new PojoWithReadObject("test-pojo"); byte[] pojoBytes = SerializationUtils.serialize(pojoObj); System.out.println(" 反序列化含 readObject() 的 POJO..."); try { PojoWithReadObject result = (PojoWithReadObject) SerializationUtils.deserialize(pojoBytes); System.out.println(" POJO 结果: " + result); } catch (Exception e) { System.out.println(" POJO 异常: " + e.getMessage()); } // 测试 Record 的 readObject(应被忽略) RecordWithReadObject recordObj = new RecordWithReadObject("test-record"); byte[] recordBytes = SerializationUtils.serialize(recordObj); System.out.println(" 反序列化含 readObject() 的 Record..."); try { RecordWithReadObject result = (RecordWithReadObject) SerializationUtils.deserialize(recordBytes); System.out.println(" Record 结果: " + result); System.out.println(" >>> 结论: Record 的 readObject() 未被调用 <<<"); } catch (Exception e) { System.out.println(" Record 异常: " + e.getMessage()); System.out.println(" >>> 结论: Record 的 readObject() 被调用了(不符合预期) <<<"); } } } ``` ### 4.6 运行与输出 ```php cd ~/record-deser-lab javac -d target/classes $(find src/main/java -name "*.java") java -cp target/classes com.seclab.basic.BasicCompareTest mvn -q exec:java -Dexec.mainClass="com.seclab.basic.BasicCompareTest" ``` 输出:   ### 4.7 字节流结构详细分析 以 POJO 的 96 字节序列化数据为例,逐段解析其结构:  篡改操作:将偏移量 `0x54` 处的 `00 00 00 19`(25)修改为 `FF FF FF FF`(-1)。 > 对 POJO 而言,`ObjectInputStream` 通过 `Unsafe.putInt()` 将 `-1` 直接写入 `age` 字段,构造函数中的 `age < 0` 校验从未执行,因此非法对象被成功创建。 > > 对 Record 而言,`ObjectInputStream` 将 `-1` 作为参数传入规范构造函数 `new RecordUser("alice", -1)`,构造函数中的校验逻辑抛出 `IllegalArgumentException`,该异常被包装为 `InvalidObjectException`,反序列化失败。 ### 4.8 序列化格式差异 > 对比 POJO 和 Record 的字节流,两者在 wire format 层面完全相同——类描述符结构、字段编码方式、数据排列顺序一致。区别仅在于: | | | | |---|---|---| | 对比项 | POJO | Record | | 序列化数据长度 | 96 bytes | 98 bytes | | 长度差异来源 | 类名 "PojoUser" 较短 | 类名 "RecordUser" 多 2 字节 | | wire format | 标准 Java 序列化格式 | 完全相同 | | 反序列化路径 | `readOrdinaryObject()` | `readRecord()`(运行时分发) | > Record 的安全保障完全依赖于反序列化端的 JDK 版本。如果同一份序列化数据被一个不支持 Record 的旧版 JDK 反序列化,安全保障将不存在。 五、实验二:Gadget 载体攻击面验证 -------------------- ### 5.1 攻击原理 > Record 反序列化的流程是:先逐一反序列化各组件的值,再调用规范构造函数。如果某个组件的类型本身含有危险的 `readObject()` 实现,恶意逻辑在组件反序列化阶段就已经执行完毕——此时 Record 的构造函数尚未被调用。 ```php [执行时序] t1: 反序列化 component[0](String 类型) → 安全 t2: 反序列化 component[1](Object 类型) → 如果是 Gadget,此处触发 readObject() → RCE t3: 调用 Record 规范构造函数 → 为时已晚 ``` ### 5.2 实验代码 GadgetTest.java ```php package com.seclab.gadget; import com.seclab.util.SerializationUtils; import java.io.*; public class GadgetTest { /** * 模拟 Gadget 类:在 readObject() 中记录触发时机和调用栈 */ static class SimulatedGadget implements Serializable { @Serial private static final long serialVersionUID = 1L; private String payload; public SimulatedGadget(String payload) { this.payload = payload; } @Serial private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); System.out.println(" [!] SimulatedGadget.readObject() 触发!payload=" + payload); System.out.println(" [!] 触发时间: " + System.nanoTime()); System.out.println(" [!] 调用栈关键帧:"); StackTraceElement[] stack = Thread.currentThread().getStackTrace(); for (StackTraceElement frame : stack) { String cls = frame.getClassName(); if (cls.contains("ObjectInputStream") || cls.contains("GadgetTest")) { System.out.println(" " + frame); } } } @Override public String toString() { return "SimulatedGadget{payload='" + payload + "'}"; } } /** * 含有 Object 类型组件的 Record——可以携带任意类型 */ record DataWrapper(String label, Object data) implements Serializable { public DataWrapper { System.out.println(" [*] DataWrapper 规范构造函数执行!"); System.out.println(" [*] 执行时间: " + System.nanoTime()); if (label == null) { throw new IllegalArgumentException("label 不能为 null"); } } } /** * 类型安全的 Record——组件类型限定为 String,不接受 Object */ record SafeWrapper(String label, String data) implements Serializable { public SafeWrapper { if (label == null || data == null) { throw new IllegalArgumentException("参数不能为 null"); } } } public static void main(String[] args) throws Exception { System.out.println("====== Record 组件类型 Gadget 载体实验 ======\n"); // --- 实验 1: Object 类型组件携带 Gadget --- System.out.println("[实验 1] Object 类型组件携带模拟 Gadget"); System.out.println(" 构造 DataWrapper(label=\"config\", data=SimulatedGadget)..."); SimulatedGadget gadget = new SimulatedGadget("calc.exe"); DataWrapper wrapper = new DataWrapper("config", gadget); byte[] serialized = SerializationUtils.serialize(wrapper); System.out.println(" 序列化完成,数据长度: " + serialized.length + " bytes\n"); System.out.println(" --- 开始反序列化(观察执行时序) ---"); DataWrapper result = (DataWrapper) SerializationUtils.deserialize(serialized); System.out.println(" --- 反序列化结束 ---\n"); System.out.println(" 最终结果: " + result); System.out.println(" >>> 结论: Gadget 的 readObject() 在 Record 构造函数之前执行 <<<"); // --- 实验 2: 类型安全 Record 的保护效果 --- System.out.println("\n[实验 2] 类型安全 Record 无法携带非 String 类型"); SafeWrapper safe = new SafeWrapper("config", "localhost:3306"); byte[] safeData = SerializationUtils.serialize(safe); SafeWrapper safeResult = (SafeWrapper) SerializationUtils.deserialize(safeData); System.out.println(" SafeWrapper 反序列化结果: " + safeResult); System.out.println(" >>> 结论: 限定组件类型可有效缩小攻击面 <<<"); } } ``` ### 5.3 运行与输出 ```php cd ~/record-deser-lab javac -d target/classes $(find src/main/java -name "*.java") java -cp target/classes com.seclab.gadget.GadgetTest mvn -q exec:java -Dexec.mainClass="com.seclab.gadget.GadgetTest" ``` 输出:  ### 5.4 调用栈分析 ```php 帧序号 调用位置 说明 ------ ------------------------------------------------ ------------------------------------ ① GadgetTest$SimulatedGadget.readObject() Gadget 的 readObject 被触发 ② ObjectInputStream.readSerialData(:2444) POJO 路径:读取序列化数据 ③ ObjectInputStream.readOrdinaryObject(:2284) POJO 路径:按普通对象处理 Gadget ④ ObjectInputStream.readObject0(:1762) 类型分发:SimulatedGadget 不是 Record ⑤ ObjectInputStream$FieldValues.<init>(:2618) Record 路径:初始化组件值 ⑥ ObjectInputStream.readRecord(:2379) Record 路径:readRecord 入口 ⑦ ObjectInputStream.readOrdinaryObject(:2278) 外层分发 ⑧ ObjectInputStream.readObject0(:1762) 最外层入口 ``` 1. 帧 ⑥ `readRecord`:ObjectInputStream 识别出 DataWrapper 是 Record,进入 `readRecord()` 路径 2. 帧 ⑤ `FieldValues.<init>`:开始构建组件值临时数组,此过程中需要逐一反序列化各组件 3. 帧 ④ `readObject0`:对 SimulatedGadget 组件进行类型分发,发现它不是 Record 而是普通类 4. 帧 ③ `readOrdinaryObject`:按传统 POJO 路径处理 SimulatedGadget,包括调用其 `readObject()` 5. 帧 ① `readObject()`:Gadget 的 readObject 执行——此时 DataWrapper 的规范构造函数尚未被调用 > Record 自身不是 Gadget,但可以成为 Gadget 的载体。 组件类型中的 `readObject()` 在 Record 构造函数之前执行,构造函数无法阻止已经发生的恶意行为。 六、实验三:反序列化过滤器纵深防御 ----------------- ### 6.1 过滤器与 Record 的交互机制 反序列化过滤器(JEP 290 / JEP 415)在 `ObjectInputStream.readObject0()` 的 `filterCheck()` 中触发。Record 类同样受到过滤器检查,且 Record 的每个组件类型也会在反序列化该组件时被独立检查:  过滤器在 Gadget 组件的 `readObject()` 被调用之前执行检查,因此能有效阻断第五章中描述的组件载体攻击。 ### 6.2 实验代码 FilterTest.java ```php package com.seclab.filter; import com.seclab.util.SerializationUtils; import java.io.*; public class FilterTest { record SecureConfig(String key, String value) implements Serializable {} static class MaliciousPayload implements Serializable { @Serial private static final long serialVersionUID = 1L; private String cmd; public MaliciousPayload(String cmd) { this.cmd = cmd; } @Serial private void readObject(ObjectInputStream in) throws Exception { in.defaultReadObject(); System.out.println(" [!] MaliciousPayload.readObject() 被执行,cmd=" + cmd); } @Override public String toString() { return "MaliciousPayload{cmd='" + cmd + "'}"; } } /** * 白名单过滤器(Lambda 实现方式) */ static ObjectInputFilter createWhitelistFilter() { return filterInfo -> { Class<?> clazz = filterInfo.serialClass(); if (clazz == null) { return ObjectInputFilter.Status.UNDECIDED; } String name = clazz.getName(); // 允许 JDK 核心类型 if (name.startsWith("java.lang.")) { return ObjectInputFilter.Status.ALLOWED; } // 允许我们的 Record 类 if (clazz == SecureConfig.class) { return ObjectInputFilter.Status.ALLOWED; } // 拒绝其他所有类 System.out.println(" [过滤器] 拒绝类: " + name); return ObjectInputFilter.Status.REJECTED; }; } /** * 基于 Pattern 的过滤器(JDK 17+ 推荐方式,一行配置) */ static ObjectInputFilter createPatternFilter() { String pattern = "com.seclab.filter.FilterTest$SecureConfig;java.lang.*;!*"; return ObjectInputFilter.Config.createFilter(pattern); } public static void main(String[] args) throws Exception { System.out.println("====== Record + 反序列化过滤器纵深防御实验 ======\n"); // --- 实验 1: 无过滤器——恶意对象可被反序列化 --- System.out.println("[实验 1] 无过滤器——恶意对象可被反序列化"); MaliciousPayload malicious = new MaliciousPayload("/bin/bash -c 'id'"); byte[] malData = SerializationUtils.serialize(malicious); try { Object result = SerializationUtils.deserialize(malData); System.out.println(" 反序列化成功: " + result); System.out.println(" >>> 恶意对象的 readObject() 已执行 <<<\n"); } catch (Exception e) { System.out.println(" 异常: " + e.getMessage() + "\n"); } // --- 实验 2: 白名单过滤器——恶意对象被拦截 --- System.out.println("[实验 2] 白名单过滤器——恶意对象被拦截"); ObjectInputFilter whitelistFilter = createWhitelistFilter(); try { Object result = SerializationUtils.deserialize(malData, whitelistFilter); System.out.println(" 反序列化成功: " + result); } catch (InvalidClassException e) { System.out.println(" >>> 过滤器拦截成功!<<<"); System.out.println(" 异常类型: " + e.getClass().getName()); System.out.println(" 异常信息: " + e.getMessage() + "\n"); } // --- 实验 3: 正常 Record 通过过滤器 --- System.out.println("[实验 3] 正常 Record 通过白名单过滤器"); SecureConfig config = new SecureConfig("db.host", "localhost"); byte[] configData = SerializationUtils.serialize(config); try { SecureConfig result = (SecureConfig) SerializationUtils.deserialize(configData, whitelistFilter); System.out.println(" 反序列化成功: " + result); System.out.println(" >>> Record 正常通过过滤器 <<<\n"); } catch (Exception e) { System.out.println(" 异常: " + e.getMessage() + "\n"); } // --- 实验 4: Pattern 过滤器(生产环境推荐) --- System.out.println("[实验 4] Pattern 过滤器(JDK 17+ 推荐方式)"); ObjectInputFilter patternFilter = createPatternFilter(); System.out.println(" 过滤器模式: com.seclab.filter.FilterTest$SecureConfig;java.lang.*;!*"); try { SecureConfig result2 = (SecureConfig) SerializationUtils.deserialize(configData, patternFilter); System.out.println(" SecureConfig 反序列化成功: " + result2); } catch (Exception e) { System.out.println(" 异常: " + e.getMessage()); } try { Object bad = SerializationUtils.deserialize(malData, patternFilter); System.out.println(" MaliciousPayload 反序列化成功: " + bad); } catch (InvalidClassException e) { System.out.println(" MaliciousPayload 被拦截: " + e.getMessage()); System.out.println(" >>> Pattern 过滤器工作正常 <<<"); } } } ``` ### 6.3 运行与输出 ```php cd ~/record-deser-lab javac -d target/classes $(find src/main/java -name "*.java") java -cp target/classes com.seclab.filter.FilterTest mvn -q exec:java -Dexec.mainClass="com.seclab.filter.FilterTest" ``` 输出:  ### 6.4 观察 对比实验 1 和实验 2 的输出可以注意到: - 实验 1(无过滤器):`readObject()` 的输出 `[!] MaliciousPayload.readObject() 被执行` 出现在反序列化结果之前,证明恶意代码已执行 - 实验 2(有过滤器):`readObject()` 的输出完全不存在,过滤器在类实例化之前就拒绝了 `MaliciousPayload` 类 > 这证实了过滤器在 `readObject()` 之前生效,能有效阻断 Gadget 执行。 > > ### 6.5 全局过滤器配置 JDK 17+ 支持通过 JVM 参数或安全配置文件设置全局过滤器: ```php # 方式一:JVM 启动参数 java -Djdk.serialFilter="com.seclab.**;java.lang.*;java.util.*;!*" -jar app.jar # 方式二:修改 $JAVA_HOME/conf/security/java.security 配置文件 # 在文件末尾追加: jdk.serialFilter=com.seclab.**;java.lang.*;java.util.*;!* ``` 过滤器表达式语法: | | | |---|---| | 模式 | 含义 | | `com.seclab.**` | 允许 com.seclab 包及子包下的所有类 | | `java.lang.*` | 允许 java.lang 包下的类(不含子包) | | `!*` | 拒绝所有未匹配的类(必须放在最后) | | `maxdepth=5` | 限制对象图最大嵌套深度 | | `maxrefs=1000` | 限制对象引用总数上限 | | `maxbytes=102400` | 限制反序列化字节流最大长度 | 七、残余攻击面——readResolve 与 Externalizable ------------------------------------- ### 7.1 readResolve() 的保留与风险 Record 仍然支持 `readResolve()` 方法。此方法在规范构造函数之后被调用,其返回值将替代原始反序列化结果: ```php record CachedToken(String value) implements Serializable { // readResolve 的合理用途:返回缓存实例,实现单例语义 @Serial Object readResolve() { return TokenCache.intern(this); } } ``` 虽然实际场景中 `readResolve()` 被滥用的可能性较低,但在代码审计时不应忽略此方法。 ### 7.2 Record 与 Externalizable 的根本矛盾 Record 类与 `Externalizable` 接口存在根本矛盾。`Externalizable` 要求类具备公共无参构造函数和可变字段(`readExternal()` 需要对字段赋值),而 Record 的所有字段均为 `final`,且只能通过规范构造函数初始化。 即使编译器未对 `implements Externalizable` 本身报错,Record 也无法满足 `Externalizable` 的运行时契约——无法声明无参构造函数,`readExternal()` 无法写入 `final` 字段。在 JDK 21 中,尝试反序列化实现了 `Externalizable` 的 Record 类会抛出 `InvalidClassException`: ```php // Record 无法满足 Externalizable 的契约 record BadRecord(String s) implements Externalizable { @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(s); } @Override public void readExternal(ObjectInput in) throws IOException { // 编译错误:Cannot assign a value to final variable 's' // this.s = in.readUTF(); } } ``` 这一限制从语言机制层面杜绝了通过 `Externalizable` 绕过 Record 安全模型的可能。 八、安全编码建议 -------- ### 8.1 数据传输对象优先使用 Record ```php // 推荐:使用 Record 定义 DTO,构造函数校验不会被反序列化绕过 public record LoginRequest(String username, String password) implements Serializable { public LoginRequest { Objects.requireNonNull(username, "username 不能为 null"); Objects.requireNonNull(password, "password 不能为 null"); if (username.length() > 64) { throw new IllegalArgumentException("username 长度不能超过 64"); } } } // 不推荐:传统 POJO 的构造函数校验在反序列化时无效 public class LoginRequestPojo implements Serializable { private String username; private String password; public LoginRequestPojo(String username, String password) { // 以下校验在反序列化时不会执行 Objects.requireNonNull(username); // ... } } ``` ### 8.2 严格限定 Record 组件类型 ```php // 危险:Object 类型的组件可以携带任意 Gadget(参见第五章实验) record UnsafeWrapper(String key, Object value) implements Serializable {} // 安全:限定为具体类型,从编译期消除 Gadget 载体可能 record SafeWrapper(String key, String value) implements Serializable {} // 如果必须支持多种类型,使用 sealed interface 约束可选类型集合 sealed interface ConfigValue extends Serializable permits StringValue, IntValue, BoolValue {} record StringValue(String val) implements ConfigValue {} record IntValue(int val) implements ConfigValue {} record BoolValue(boolean val) implements ConfigValue {} record TypeSafeConfig(String key, ConfigValue value) implements Serializable {} ``` 九、技术认知与总结 --------- ### 9.1 Record 反序列化安全机制的本质 Record 的反序列化安全性并非来自某种专门的安全加固,而是其语言设计的自然结果。Record 的不可变性(`final` 字段)和值语义要求所有实例必须经由规范构造函数创建——这一约束在序列化规范中得到了忠实的实现。安全性是正确性的副产品,这恰恰是最可靠的安全保障形式。 ### 9.2 安全边界的准确认知 Record 反序列化解决的是"被反序列化对象自身的不变量保护"问题,而非"反序列化过程中的任意代码执行"问题。后者的根源在于危险类型的 `readObject()` 方法,这超出了 Record 机制的保护范围。准确理解这一边界,对于制定有效的防御策略至关重要。 > 本文实验中 Gadget 调用栈(第 5.4 节)清晰地展示了这一点:在 `readRecord()` 处理 Record 反序列化的过程中,仍然会通过 `readOrdinaryObject()` 处理组件中的传统 POJO 对象,后者遵循传统的不安全路径。 ### 9.3 纵深防御架构 单一安全机制无法提供完整防护。推荐的防御分层:  - - - - - - 参考资料: 1. JEP 395: Records — <https://openjdk.org/jeps/395> 2. JEP 290: Filter Incoming Serialization Data — <https://openjdk.org/jeps/290> 3. JEP 415: Context-Specific Deserialization Filters — <https://openjdk.org/jeps/415> 4. Java Object Serialization Specification — <https://docs.oracle.com/en/java/javase/21/docs/specs/serialization/> 5. OpenJDK `ObjectInputStream.java` 源码(JDK 21) **文中涉及的技术内容仅用于安全研究与防御建设,请勿用于非法用途。**
发表于 2026-06-26 09:38:46
阅读 ( 4019 )
分类:
WEB安全
0 推荐
收藏
0 条评论
Dracarys
6 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!