所有依赖 Fastjson 版本 1.2.80 或更早版本的程序,在应用程序中如果包含使用用户数据调用 JSON.parse 或 JSON.parseObject 方法,但不指定要反序列化的特定类,都会受此漏洞的影响。

在之前的研究中,针对 fastjson 1.2.80 已经有了三种常见的利用场景,你可以参考 GitHub 项目:su18/hack-fastjson-1.2.80。

漏洞复现
需要的依赖:
攻击思路:
- 将
InputStream 放入 fastjson 缓存。
- 读取
/tmp 文件下的文件,找到 docbase 的文件名。
- 往
${docbase}/WEB-INF/classes/ 路径下写入恶意类。
- 通过 fastjson 触发类加载。
已有相关的利用工具,可参考 GitHub 项目:ph0ebus/CVE-2022-25845-In-Spring。

漏洞分析
cache
这个新的利用链同样利用了 fastjson 的缓存机制。

fastjson 反序列化符合条件的期望类时,会将 setter 参数、public 字段、构造函数参数的类型加到缓存中。

我们先来分析一下添加缓存的具体过程。以下面的 payload 为例:
{"@type":"java.lang.Exception","@type":"com.fasterxml.jackson.core.exc.InputCoercionException"}

在 TypeUtils.getClassFromMapping() 中,代码会首先尝试从缓存中获取 java.lang.Exception 类。

在 com.alibaba.fastjson.util.TypeUtils#addBaseClassMappings 初始化方法中,默认添加了一些基础类作为缓存,其中就包含 Exception.class。

此时,缓存(mappings)中已经有了 95 个类。

从缓存中获取到 class 后返回,然后继续恢复其字段信息。com.alibaba.fastjson.parser.ParserConfig#getDeserializer 方法会先通过获取到的 class 来寻找对应的反序列化器。


可以跟踪到这行关键的判断代码:

根据Java异常体系的继承关系可以发现,java.lang.Exception 类符合 Throwable.class.isAssignableFrom(clazz) 这个判断条件,于是其反序列化器被设置为 ThrowableDeserializer。

在 com.alibaba.fastjson.parser.deserializer.ThrowableDeserializer#deserialze 反序列化过程中,会将 Exception 作为期望类(expectClass)。

然后解析 json 中的键值对,这里的 key 是 @type。

当 key 为 @type 时,会将 Throwable.class 作为期望类传入 com.alibaba.fastjson.parser.ParserConfig#checkAutoType() 进行校验。


传入的类名需要经过黑名单过滤和白名单校验。

继续跟进到这段代码,会根据传入的 typeName 来加载类。加载后,如果该类是期望类(Throwable.class)的子类,则会被加入到缓存 mappings 中。

read
我们进一步分析一下实现任意文件读取的 payload:
{
"a": "{ \"@type\": \"java.lang.Exception\", \"@type\": \"com.fasterxml.jackson.core.exc.InputCoercionException\", \"p\": { } }",
"b": {
"$ref": "$.a.a"
},
"c": "{ \"@type\": \"com.fasterxml.jackson.core.JsonParser\", \"@type\": \"com.fasterxml.jackson.core.json.UTF8StreamJsonParser\", \"in\": {}}",
"d": {
"$ref": "$.c.c"
}
}
利用循环引用($ref)尝试将字符串转换为对象并获取对象的值,按作者的话来说,这里是利用 JsonPath 来绕过原有的异常处理逻辑。
接着上面的分析,在恢复好 com.fasterxml.jackson.core.exc.InputCoercionException 对象后,代码会继续利用 com.alibaba.fastjson.parser.deserializer.ThrowableDeserializer#deserialze 获取字段。根据 key 实例化出 FieldDeserializer 进行进一步处理。

继续,会调用 TypeUtils#cast 方法进行类型转换。

com.alibaba.fastjson.util.TypeUtils#cast(java.lang.Object, java.lang.Class<T>, com.alibaba.fastjson.parser.ParserConfig) 会根据传入的 obj 类型进行相应的转换。这里 obj 是一个空的 Map,因此会进入 Map 类型这个分支。

跟进到 com.alibaba.fastjson.util.TypeUtils#castToJavaBean(java.util.Map<java.lang.String,java.lang.Object>, java.lang.Class<T>, com.alibaba.fastjson.parser.ParserConfig)。该方法会根据目标类型 clazz(此处为 com.fasterxml.jackson.core.JsonParser)获取对应的反序列化器。

获取到反序列化器后,会调用 putDeserializer 函数:this.deserializers.put(type, deserializer)。

这里就会将 type 和 deserializer 存入 com.alibaba.fastjson.util.IdentityHashMap#buckets 中。

在后续恢复 com.fasterxml.jackson.core.JsonParser 类型时,调用 this.deserializers.findClass(typeName) 就可以从 com.alibaba.fastjson.util.IdentityHashMap#buckets 中获取到这个类。


而 com.fasterxml.jackson.core.json.UTF8StreamJsonParser 是 com.fasterxml.jackson.core.JsonParser 的子类。类似前面利用 java.lang.Exception 恢复 com.fasterxml.jackson.core.exc.InputCoercionException 一样,利用父类缓存可以绕过检查来反序列化子类。

在实现 JsonParser 接口的类中,只有 UTF8StreamJsonParser 的构造参数包含 InputStream 类型,因此可以进一步利用其构造函数将 InputStream 对象放入缓存。
public UTF8StreamJsonParser(IOContext ctxt, int features, InputStream in, ObjectCodec codec, ByteQuadsCanonicalizer sym, byte[] inputBuffer, int start, int end, int bytesPreProcessed, boolean bufferRecyclable) {
super(ctxt, features);
this._quadBuffer = new int[16];
this._inputStream = in;
this._objectCodec = codec;
this._symbols = sym;
this._inputBuffer = inputBuffer;
this._inputPtr = start;
this._inputEnd = end;
this._currInputRowStart = start - bytesPreProcessed;
this._currInputProcessed = (long)(-start + bytesPreProcessed);
this._bufferRecyclable = bufferRecyclable;
}

而获取 InputStream 的最终目的,就是为了实现任意文件读取。其核心原理在 BlackHat USA 2021 的议题《How I Used a JSON Deserialization Vulnerability to Read Arbitrary Files》中有详细阐述。
这里的利用链是通过 org.apache.commons.io.input.BOMInputStream 来逐字节盲读取文件。

在 org.apache.commons.io.input.BOMInputStream#getBOM 中会调用 org.apache.commons.io.input.BOMInputStream#find 方法。

跟进 find 方法可以发现,这里先把 delegate 输入流的字节码转成 int 数组,然后拿 ByteOrderMark 对象里的字节数组挨个遍历去比对。如果遍历过程有比对错误的,getBom 方法就会返回 null;如果遍历结束没有比对错误,就会返回一个 ByteOrderMark 对象。

因此,通过控制 ByteOrderMark 的字节序列与目标文件字节的匹配与否,即可实现逐字节盲读。最终,输入流的来源是 jdk.nashorn.api.scripting.URLReader,其构造函数 public URLReader(URL url) 可以传入一个 URL 对象,这意味着 file、jar、http 等协议都可以使用。在漏洞利用中,通常传入 file 协议来读取目录或文件。
write
接下来分析一下实现任意文件写入的 payload(已简化结构):
{
"a": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.AutoCloseInputStream",
"in": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"cs": "${shellcode}",
"charset": "iso-8859-1",
"bufferSize": ${size}
},
"branch": {
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type": "org.apache.commons.io.output.LockableFileWriter",
"file": "${file2write}",
"charset": "iso-8859-1",
"append": true
},
"charset": "iso-8859-1",
"bufferSize": 1024,
"writeImmediately": true
},
"closeBranch": true
}
},
"b": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.XmlStreamReader",
"inputStream": {
"$ref": "$.a"
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "iso-8859-1"
},
"charsetName": "iso-8859-1",
"bufferSize": 1024
},
"c": {}
}
这个链子和 BlackHat 议题提到的有很多共通之处,都是利用 org.apache.commons.io.input.TeeInputStream#read() 方法在读取数据的同时,将数据写入分支(branch)输出流。

但是,作者似乎找到了一个更好的链子,规避了原 Poc 链中存在的写入缓冲区 8192 字节限制。


write2RCE
最后需要探讨的是,在实现任意文件写入后,如何进一步达到远程代码执行(RCE)。
常见的做法例如覆盖 charsets.jar,利用 JVM 的懒加载机制,覆盖 JDK HOME 目录下原有的、尚未被加载的 charsets.jar 包。但这种方法需要知道 JDK HOME 路径、可能需要 root 权限、且需针对目标 JDK 版本准备恶意 jar 包,否则可能影响服务。
另一种思路是利用类加载机制,在 jdk home 目录下的 classes 目录中写入恶意 class 文件,然后通过 fastjson 的 @type 触发加载。
这里,作者利用了另一种类加载路径。在 fastjson 反序列化过程中,针对不在黑白名单且缓存中没有的类,会通过 com.alibaba.fastjson.util.TypeUtils#loadClass() 尝试加载。该方法会使用当前线程的上下文类加载器,在 Spring Boot 内嵌 Tomcat 环境下,通常是 TomcatEmbeddedWebappClassLoader。

根据双亲委派机制,最终会委派给 WebappClassLoaderBase 来加载。一路跟下去,可以在 org.apache.catalina.loader.WebappClassLoaderBase#findClass 中看到它会调用 org.apache.catalina.loader.WebappClassLoaderBase#findClassInternal 方法来寻找类。

跟进 findClassInternal 方法。

进一步跟进 org.apache.catalina.webresources.StandardRoot#getClassLoaderResource 来追踪类资源加载的路径。


这里会判断 isCachingAllowed(),而 cachingAllowed 属性默认为 true。
public boolean isCachingAllowed() {
return this.cachingAllowed;
}

因此,会进入 org.apache.catalina.webresources.Cache#getResource 方法。

首先会调用 noCache 方法。对于路径以 .class 结尾且以 /WEB-INF/classes/ 或 /WEB-INF/lib/ 开头的资源,该方法会返回 true,从而直接调用 this.root.getResourceInternal(path, useClassLoaderResources) 绕过缓存。
private boolean noCache(String path) {
return path.endsWith(".class") && (path.startsWith("/WEB-INF/classes/") || path.startsWith("/WEB-INF/lib/")) || path.startsWith("/WEB-INF/lib/") && path.endsWith(".jar");
}
跟进 org.apache.catalina.webresources.StandardRoot#getResourceInternal。

最终,资源查找会落到 DirResourceSet 等资源集合上。如果请求的 class 文件在对应的目录(如 ${docbase}/WEB-INF/classes/)下存在,就会正常返回该文件资源。

因此,通过前面的任意文件写漏洞,将恶意 class 文件写入 ${docbase}/WEB-INF/classes/com/example/poc/PocException.class 路径后,再通过 fastjson 反序列化触发对该类(com.example.poc.PocException)的加载,即可成功执行恶意代码,达到 RCE 的目的。
总结
这条针对 Fastjson 1.2.80 的利用链,巧妙地将缓存绕过、逐字节文件读取、任意文件写入以及 Spring Boot 内嵌容器的特定类加载路径结合了起来,构成了一条完整的攻击链。理解这条链需要对 Java 反序列化机制、Fastjson 内部实现以及 Tomcat 类加载原理有较深的认识。对于开发者而言,及时升级 Fastjson 版本、避免反序列化不可信数据是根本的防御措施。对于安全研究者,这类漏洞的挖掘与分析,则深化了我们对安全防御机制和攻击面转化的理解。
本文技术分析基于公开安全研究,仅供学习交流。更多深入的技术讨论和实战资源,欢迎访问 云栈社区 的 Java 与安全板块。