SnakeYaml反序列化

SnakeYaml 是java解析yaml格式的组件库,它可以把yaml格式的数据转化为java对象 这个过程叫做反序列化

这个漏洞还挺厉害的 能打全版本

Yaml介绍

学习反序列化漏洞之前,先大致看看Yaml是什么

YAML(YAML Ain't Markup Language)是一种数据序列化语言,通常用于编写配置文件。它的设计目标是易于人类阅读和编写,同时也易于计算机解析和生成。YAML的语法和其他高级语言类似,可以简单表达清单、散列表,标量等数据形态。YAML文件的扩展名通常为*.yml.yaml*。

语法特点:

YAML文件对空格要求极其严格,使用缩进表示层级关系,缩进不允许使用tab,只允许空格。缩进的空格数不重要,只要相同层级的元素左对齐即可。注释使用*#*表示。

YAML支持多种数据类型,包括:

  • 对象:键值对的集合,也称为映射(mapping)/哈希(hashes)/字典(dictionary)。
  • 数组:一组按次序排列的值,也称为序列(sequence)/列表(list)。
  • 纯量(scalars):单个的、不可再分的值,如字符串、布尔值、整数、浮点数、Null、时间和日期。

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

反序列化漏洞复现

原理分析

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

源码分析

分析load方法

load:

根据传入的yaml字符串创建一个流,然后传入Object类的class

步入loadFromReader:

private Object loadFromReader(StreamReader sreader, Class<?> type) {
    Composer composer = new Composer(new ParserImpl(sreader), this.resolver, this.loadingConfig);
    this.constructor.setComposer(composer);
    return this.constructor.getSingleData(type);
}

在new ParserImpl(sreader) 对我们传进来的yaml流进行映射(也就是对象,其中调用getSingleNote()会根据流来生成一个文件,即将字符串按照yaml语法转为Node对象)看看具体逻辑

new ParserImpl(sreader):

发现进行了重载,继续跟

利用重载拿到相关映射

public ParserImpl(Scanner scanner) {
    this.scanner = scanner;
    this.currentEvent = null;
    this.directives = new VersionTagsTuple((DumperOptions.Version)null, new HashMap(DEFAULT_TAGS));
    this.states = new ArrayStack(100);
    this.marks = new ArrayStack(10);
    this.state = new ParseStreamStart();
}

然后这个方法就走完了,接着就是new Composer加载默认配置并解析开头和结尾的位置

紧接着 调用 getSingleData(type)方法 跟进看看

public Object getSingleData(Class<?> type) {
    Node node = this.composer.getSingleNode();
    if (node != null && !Tag.NULL.equals(node.getTag())) {
        if (Object.class != type) {
            node.setTag(new Tag(type));
        } else if (this.rootTag != null) {
            node.setTag(this.rootTag);
        }

        return this.constructDocument(node);
    } else {
        Construct construct = (Construct)this.yamlConstructors.get(Tag.NULL);
        return construct.construct(node);
    }
}

调用getSingleNode()方法,将整个yaml数据解析成多个节点 跟进看看

public Node getSingleNode() {
    this.parser.getEvent();
    Node document = null;
    if (!this.parser.checkEvent(ID.StreamEnd)) {
        document = this.getNode();
    }

跟进getNode()方法

public Node getNode() {
    this.parser.getEvent();
    Node node = this.composeNode((Node)null);
    this.parser.getEvent();
    this.anchors.clear();
    this.recursiveNodes.clear();
    return node;
}

跟进composeNode()方法 ,在这个方法中实现节点解析

具体实现就不看了 我们挑重点来看:

这里会进行一个判断 判断 YAML 流中当前事件是否为别名引用 (这里会有一个把!!转换会tag的逻辑,到后面会很有用,这里先看看)

跟进

跟进peekEvent()

继续跟进:

还得跟进:

parseNode方法 就是在这里进行处理的:

被处理成了一个tag头

然后就没什么看的了 一路调试到

getClassFornode方法

从tag中去除前缀,拿到全类名,进入getClassForName进行类加载

protected Class<?> getClassForNode(Node node) {
    Class<? extends Object> classForTag = (Class)this.typeTags.get(node.getTag());
    if (classForTag == null) {
        String name = node.getTag().getClassName();

        Class cl;
        try {
            cl = this.getClassForName(name);
        } catch (ClassNotFoundException var6) {
            throw new YAMLException("Class not found: " + name);
        }

        this.typeTags.put(node.getTag(), cl);
        return cl;
    } else {
        return classForTag;
    }
}

getClassForName(name):

protected Class<?> getClassForName(String name) throws ClassNotFoundException {
    try {
        return Class.forName(name, true, Thread.currentThread().getContextClassLoader());
    } catch (ClassNotFoundException var3) {
        return Class.forName(name);
    }
}

接着追溯发现是调用newInstance对相关的class进行实例化操作;拿到User实例;

然后跟着程序来到constructJavaBean2ndStep函数中;

在经过flattenMapping函数之时,触发对class的内省操作,拿到class中的相关的属性以及其数量;后续调用getValue函数对Node节点进行处理,拿到相关的值;如下图:

接着就开始对这两个节点开始循环处理,不用想此处就是赋值的操作了;

在while中循环进行处理,会先判断相关属性是否可写;

如果可写则进入后续的流程:

采用反射进行最后的处理赋值;这个点的原理就是调用相关的setter了;看下图:

最后就可进行属性的赋值;另一个属性也是这样的原理,也是最后触发setter对其进行赋值;最后实例化结束return回去对象;

漏洞分析:

这个其实跟fastjson很像 在反序列化的时候会通过反射来自动调用setter方法,那么就攻击点跟fastjson很像了

Yaml.load(),不存在某个属性,或者存在属性但是不是由public修饰的时候会调用set方法

拓展攻击面

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/中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了

举个很简单的例子:

先定义一个简单的接口:

package org.example;

public interface text {
    public void print();
}

然后我们去写两个实现类:

package org.example;

import java.io.IOException;

public class text1 implements text {

    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void print() {
        System.out.println("text1");
    }
}
package org.example;

public class text2 implements text{
    @Override
    public void print() {
        System.out.println("text2");
    }
}

然后在META-INF/services 新建一个以接口命名的文件 文件内容就是我们调用的具体类(我们写了两个实现类,但是文件内容我只写了一个)

调用SPI:

package org.example;

import java.util.ServiceLoader;

public class SPI {
    public static void main(String[] args) {
        ServiceLoader s = ServiceLoader.load(text.class);
        s.forEach(System.out::println);
        System.out.println("hello");
    }
}

可以看到对text1类进行了实例化 ,并且只调用了text1

SPI大致底层代码实现逻辑

断点直接打在这里

步入

可以看到先是通过线程来获得线程上下文类加载器,我们传入的字节码参数变成了service,然后调用重载方法

然后拿着字节码和类加载器去进行实例化ServiceLoader类

我们可以先看看ServiceLoader类的属性:

可以看到PREFIX中定义了默认读取路径 ,providers中会先实例化一个LinkedHashMap类

接下来步入构造方法中:

先是会进行一个判断传入的字节码是否为空,避免空指针异常

然后就是一个三目运算,赋值类加载器

然后执行reload方法,步入

先是清空之前的缓存,然后对lookupIterator进行实例化(这个是一个懒加载迭代器,需要在后续的遍历时才会进行真正的加载),传入之前的字节码和类加载器,load方法就到此结束

接下来 我们来看遍历过程中如何读取的接口和文件内容来实现类加载:

先进入了 forEach方法,先检查空指针,然后就是进行迭代

步入增强for方法

如何调试增强for方法呢:

实际上调用的代码如下:

Iterator<T> it = this.iterator();
while (it.hasNext()) {
    T t = it.next();
    action.accept(t);
}

先是去读取providers中的缓存

然后到hasNext方法:

先判断是否有缓存,没有的话就调用lookupIterator的hasNext方法

一个if判断 然后调用hasNextService方法,步入

在这个方法中 先是将路径拼接,如果类加载不为空就调用自定义的类加载器,为空则调用默认,然后调用parse方法去读取文件中的内容

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        try {
            String fullName = PREFIX + service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                configs = loader.getResources(fullName);
        } catch (IOException x) {
            fail(service, "Error locating configuration files", x);
        }
    }
    while ((pending == null) || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}

然后对nextName赋值为文件内容,然后在返回true

然后在步入到next方法:

依旧先看缓存,然后在去执行lookupIterator的next方法

然后步入nextService方法

然后在这个方法中 根据类名和加载器来获得class 然后在进行实例化

ScriptEngineManager结合SPI机制

yaml反序列化逻辑已经是调试过了的 这里就不重复了

先看看ScriptEngineManager的构造方法:

一个是无参构造方法,会调用默认的上下文类加载器,一个是接收传入的类加载器

很明显我们想要远程类加载,那就必须是调用下面这个,那么刚好,在yaml反序列化的时候

步入init方法

步入initEngines方法:

在initEngines方法,发现会先默认调用ServiceLoader方法,而且传入的是ScriptEngineFactory接口的字节码,那么说明我们的恶意类是需要是这个接口的实现类

然后就是 先给itr赋值为 懒加载迭代器,然后调用hasNext方法获取路径

后面的流程就参考上面已经说过的

总结:

可以利用SPI机制进行漏洞利用主要是因为可以传入URLload加载器,并且在方法中硬编码传入了ScriptEngineFactory接口,那么我们的恶意类去实现这个接口就可以进行漏洞利用(在 Jar 包必须有一个以接口全限定名命名的文件: META-INF/services/javax.script.ScriptEngineFactory,文件内容写上恶意类全路径名)