监控反序列化以提高应用程序安全性

许多 Java 框架依赖于序列化和反序列化,以便在不同计算机上的 JVM 之间交换消息或将数据持久保存到磁盘。监控反序列化对于使用此类框架的应用程序开发人员很有帮助,因为它提供了对最终流入应用程序的底层数据的见解。反过来,这种见解有助于配置序列化过滤,这是 Java 9 中引入的一种机制,用于在数据到达应用程序之前对其进行筛选,从而防止漏洞。框架开发人员还可以受益于一种更有效、更动态的方法来监控其框架执行的反序列化活动。


bunch og gears seen trough a mangifying glass
照片由 Shane Aldendorff 提供


不幸的是,监控反序列化很困难,因为它需要高级知识,了解 Java 类库如何执行反序列化。例如,你必须使用脆弱的技术,例如调试或检测 java.io.ObjectInputStream 中的方法调用。一种更好的方法是使用 JDK Flight Recorder (JFR),这是一种低开销数据收集框架,用于对 Java 应用程序和 Oracle 在 Java 11 中开源的 HotSpot JVM 进行故障排除。从 Java 17 开始,JFR 通过新的反序列化事件为反序列化提供了一流的支持,该事件由反序列化操作触发。你可以将 JFR 与 JDK Mission Control (JMC) 等工具一起使用,以识别 ObjectInputStream 对对象的反序列化。有了这个,你可以识别特定类的对象的反序列化,或监控未配置序列化过滤器的反序列化操作,甚至可以监控被过滤器拒绝或允许的操作。

本文介绍了新的反序列化事件,描述了如何启用它,以及最后如何利用它来深入了解正在运行的 JVM 中发生的反序列化操作。

JFR 反序列化事件

在 Java 平台中,ObjectInputStream 执行反序列化操作。也就是说,ObjectInputStream::readObject 从字节流中“静止”表示形式生成 Java 堆中的活动对象。现在,当 ObjectInputStream 处理字节流以重建对象图时,会引发 JFR 反序列化事件,以了解其内部工作原理。

针对流中的每个新对象都会创建一个反序列化事件。该事件会捕获与流中特定对象相关的信息,例如正在反序列化的对象的类,以及其他信息,如序列化过滤器的状态(如果已配置)。反序列化事件捕获的信息与报告给序列化过滤器的信息有很多相似之处,但要明确的是,反序列化事件的创建与是否配置了过滤器无关。

反序列化事件捕获

  • 是否配置了序列化过滤器。
  • 序列化过滤器状态(如果已配置)。
  • 正在反序列化的对象的类。
  • 反序列化数组时的数组元素数量。
  • 当前图形深度。
  • 当前对象引用的数量。
  • 流中已消耗的当前字节数。
  • 如果序列化过滤器抛出异常,则抛出异常类型和消息。

我们来看一个具体示例。

设置示例

为了尽可能简单,本示例仅使用 Java 开发工具包 (JDK) 中的工具。您需要构建 JDK 17,目前处于早期访问阶段。

为了触发创建反序列化事件,我们首先需要一个可以序列化和反序列化的类。我们使用一个可序列化的记录类

package q;

record Point(int x, int y) implements java.io.Serializable { }

我们需要几个小型实用方法来执行序列化和反序列化

/** Returns a serialized byte stream representation of the given obj. */
public static <T> byte[] serialize(T obj) throws IOException {
    try(var baos = new ByteArrayOutputStream();
        var oos = new ObjectOutputStream(baos)) {
        oos.writeObject(obj);
        oos.flush();
        return baos.toByteArray();
    }
}
/** Returns (reconstitutes) an object from a given serialized byte stream. */
static <T> T deserialize(byte[] streamBytes) throws Exception {
    try (var bais = new ByteArrayInputStream(streamBytes);
         var ois  = new ObjectInputStream(bais)) {
        return (T) ois.readObject();
    }
}

最后,一个执行反序列化的小型程序

public class BasicExample {
    public static void main(String... args) throws Exception {
        byte[] serialBytes = serialize(new q.Point(5, 6));
        q.Point p = deserialize(serialBytes);
    }
}

小型程序 BasicExample 首先序列化一个 Point 对象,以生成一些已知的串行字节流数据。第二部分更有趣,因为它执行反序列化操作,我们将使用 JFR 捕获并检查该操作。

有了上述部分,我们现在拥有生成和分析反序列化事件所需的一切。

使用 JFR 运行

可以使用 jcmd 等工具动态启用和禁用 JFR 记录。但出于演示目的,最直接的方法是传递一个命令行参数,以便 java 启动器设置 JFR 开始记录。

$ java -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 2884 JFR.dump name=1 to copy recording data to file.

StartFlightRecording 选项接受多个参数; filename 是捕获记录的输出文件; settings 是包含 JFR 配置的文件,在本例中,它被设置为当前目录中的一个文件 deserEvent.jfc,它启用反序列化事件,如下所示

<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0" description="test">
    <event name="jdk.Deserialization">
       <setting name="enabled">true</setting>
       <setting name="stackTrace">false</setting>
    </event>
</configuration>

在本例中,由于我们只对一种事件感兴趣,即 jdk.Deserialization,因此在配置文件中只启用了该事件。

当程序终止时,recording.jfr 包含记录的事件。我们使用 JDK 的 jfr 命令行工具来打印记录

$ jfr print recording.jfr
jdk.Deserialization {
  startTime = 19:55:55.773
  filterConfigured = false
  filterStatus = N/A
  type = q.Point (classLoader = app)
  arrayLength = -1
  objectReferences = 1
  depth = 1
  bytesRead = 34
  exceptionType = N/A
  exceptionMessage = N/A
  eventThread = "main" (javaThreadId = 1)
}

打印了一个单一的反序列化事件,这是预期的,因为该程序仅执行一个平凡的 Point 对象的 deserialize 操作。 type 字段标识正在反序列化的对象类,在此情况下为 q.Point。流对象不是数组,因此 arrayLength 不适用,其值为 -1。此反序列化事件捕获流中的第一个对象引用,因此 objectReferences 的值为 1bytesRead 字段提供在创建事件时从串行字节流中读取的总字节数,在本例中为“34”个字节。

鉴于使用序列化过滤器的重要性及潜在的安全优势,事件中包含许多与其相关的字段。 filterConfigured 的值为一个 布尔值,指示是否配置了序列化过滤器。在此情况下未配置过滤器,因此其值为 falsefilterStatus 包含过滤器决策状态的值,但由于未配置过滤器,因此其值为 不适用 (N/A)。最后, exceptionTypeexceptionMessage 捕获从过滤器抛出的任何异常详细信息(如果有),但由于未配置过滤器,因此这些字段的值为 不适用 (N/A)

使用序列化过滤器运行

一种相对常见的过滤方法是配置一个防御性拒绝列表,拒绝不信任的类列表。(如果已知类集合,则首选允许列表)

让我们再次运行相同的程序,但这次使用序列化过滤器配置来拒绝反序列化其类为 q.Point 的对象。为此,我们在命令行上使用 jdk.serialFilter 属性,它允许在不更改程序代码的情况下定义基于模式的过滤器。我们将提供一个基本模式,该模式匹配以“!”字符开头的类名(匹配以“!”开头的模式的类将被拒绝)。

$ java -Djdk.serialFilter='!q.Point' -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc  -cp target/ q.BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 14725 JFR.dump name=1 to copy recording data to file.
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
	at java.base/java.io.ObjectInputStream.filterCheck(ObjectInputStream.java:1378)
	at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2032)
	at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1889)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2196)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1706)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:496)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:454)
	at serial.Utils.deserialize(Utils.java:46)
	at BasicExample.main(BasicExample.java:32)

程序在反序列化操作期间抛出异常,正如预期的那样(因为过滤器配置为拒绝反序列化其类为 q.Point 的对象)。让我们看看为此次特定程序运行创建的反序列化事件

$ jfr print recording.jfr
jdk.Deserialization {
  startTime = 11:06:02.865
  filterConfigured = true
  filterStatus = "REJECTED"
  type = q.Point (classLoader = app)
  arrayLength = -1
  objectReferences = 1
  depth = 1
  bytesRead = 34
  exceptionType = N/A
  exceptionMessage = N/A
  eventThread = "main" (javaThreadId = 1)
  stackTrace = [ ... ]
}

我们可以看到 filterConfigured 的值现在为 true,反映了已配置过滤器的这一事实。 filterStatus 显示 REJECTED,因为反序列化操作被拒绝(正如预期的那样)。

总结

Java 17 中添加了一个反序列化事件,允许监视和检查平台序列化 API 执行的所有反序列化操作,即 ObjectInputStream。启用反序列化事件进行记录可以回答以下问题:所有反序列化操作是否都使用已配置的过滤器,或者 Foo 类有任何反序列化操作,或者在给定时间段内有多少反序列化操作被拒绝。

反序列化事件是一个 JFR 事件,因此可以由 JDK Mission ControlAdvanced Management Console 等工具使用,以动态监控和检查正在运行的 JVM 或远程系统上多个 JVM 中感兴趣的反序列化操作。

如上所述的完整源代码可以在 此处 找到。