前言
很早之前就看到过LandGrey师傅写的文章Spring Boot FatJar写文件漏洞到稳定RCE的探索,凑巧在暑期实习面试的时候面试官也问到了相关问题,如何对一个存在Fastjson任意文件写漏洞的SpringBoot项目来实现RCE?常规的一些方式可能是通过写计划任务、替换so/dll等系统文件进行劫持等来实现RCE,但是实际情况下往往受限于权限、网络等问题,因此从Java代码层面来寻找一种RCE的方式更为贴切。
前置
FatJar
FatJar通常也称为uber-JAR,是一种Java归档文件,它不仅包括应用程序的编译源代码,还包括运行应用程序所需的所有依赖项和资源。这些依赖项可能包括库、框架,甚至是嵌入式服务器,如Tomcat或Jetty,这些通常在SpringBoot应用中使用。
换句话说,FatJar是一个独立可直接运行的软件包。与普通JAR文件不同的是,普通JAR文件在运行应用程序时可能需要在classpath中存在外部依赖,而FatJAR文件则完全自给自足,可以在任何已安装Java虚拟机的系统上运行,无需安装或设置任何额外的依赖。
类加载
类从被加载到 JVM 开始,到卸载出内存,整个生命周期分为七个阶段,分别是加载、验证、准备、解析、初始化、使用和卸载。其中,验证、准备和解析这三个阶段统称为连接。系统加载Class类型的文件主要三步,加载->连接->初始化;连接过程又可分为三步,验证->准备->解析。
类加载器是一个负责加载类的对象,主要作用就是动态加载Java类的字节码(.class文件)到JVM中(在内存中生成一个代表该类的Class对象)。其中,字节码可以是Java源程序(.java文件)经过javac编译得来,也可以是通过工具动态生成或者通过网络下载得来。
类装载 (Class loading) 和类初始化 (Class initialization) 通常并称为类加载。
类装载是由JVM的不同ClassLoader,包括Bootstrap Classloder、Extention ClassLoader、App ClassLoader和用户自定义的Classloder完成。类装载通常是一个Class在字节码中引用另一个Class时被动触发的,也有通过Classloder#loadClass和Class#forName等方式主动触发的。
利用命令”java -XX:+TraceClassLoading xxx.jar”可以观察到类装载的过程如下,其中,Opened操作代表打开指定文件,通常表示第一次读取相关字节码到内存;Loaded操作代表将读取的指定类的字节码进行装载。
1 | [Opened /Library/Java/JavaVirtualMachines/jdk1.8.0_65.jdk/Contents/Home/jre/lib/rt.jar] |
类初始化阶段是类加载过程的最后一个阶段,它主要执行类的初始化代码,包括静态变量赋值和静态代码块的执行。以下是类初始化的具体过程:
- 创建类的实例:通过new关键字、反射(Class.forName)、克隆(clone)或反序列化(ObjectInputStream.readObject)等方式创建类的实例时,会触发类初始化;
- 调用类的静态方法:当调用类的静态方法时,会触发类初始化;
- 访问类的静态字段:当访问类的静态字段时,会触发类初始化,但通过子类引用父类的静态字段,不会触发子类的初始化;
- 反射调用:通过反射方式访问类时,如Class.forName,会触发类初始化;
- 初始化子类:当初始化一个类时,如果其父类还没有初始化,会先触发父类的初始化;
- 虚拟机启动时:虚拟机启动时,会初始化指定的主类。
类加载器的加载流程
- 首先,ClassLoader会调用loadClass方法加载类;
- loadClass方法先调用findLoadedClass方法检查类是否已经初始化,如果JVM已初始化过该类则直接返回类对象;
- 如果创建当前ClassLoader时传入了父类加载器,就使用父类加载器加载类,否则使用Bootstrap ClassLoader进行加载;
- 如果上一步无法加载类,那么调用自身的findClass方法尝试加载类;
- 如果当前的ClassLoader没有重写了findClass方法,那么直接返回类加载失败异常;如果当前类重写了findClass方法并通过传入的类名找到了对应的类字节码,那么调用defineClass方法去JVM中注册该类;
- 如果调用loadClass的时候传入的resolve参数为true,那么还需要调用resolveClass方法链接类,默认为false;
- 最后,返回一个被JVM加载后的java.lang.Class类对象。
双亲委派机制
由于App ClassLoader只负责加载classpath下的类库,因此当App ClassLoader遇到没有加载的系统类库时,会将系统类库的加载工作交给Bootstrap ClassLoader和Extension ClassLoader,这就是双亲委派。
在下图中,App ClassLoader在加载一个未知的类名时,并不是立即去搜寻classpath,它会首先将这个类名称交给Extension ClassLoader来加载,如果Extension ClassLoader可以加载,那么App ClassLoader就不会进行加载,否则的话App ClassLoader会搜索classpath。Extension ClassLoader在加载一个未知的类名时,也并不是立即搜寻ext路径,它会首先将类名称交给Bootstrap ClassLoader来加载,如果Bootstrap ClassLoader可以加载,Extension ClassLoader也不会对其进行加载,否则的话才会搜索ext路径下的jar包。
App ClassLoader、Extension ClassLoader、Bootstrap ClassLoader三者之间形成了一个级联的父子关系,优先把任务交给其父亲,当其父亲无法完成任务时才会轮到自己,在每个ClassLoader对象的内部都会存在一个parent属性指向自己的父加载器。
利用
上文提到了,在类初始化阶段时会执行static代码块、static属性引用的方法等,还可能执行构造器中的代码。在类装载的过程中可以看到应用程序在首次启动时会装载很多类,同时,在应用程序运行过程中,会因为部分代码首次执行或首次异常报错而触发一些类的装载和初始化,
如果能够找到一种方法,控制应用程序在指定的文件写漏洞可控的文件范围内,主动触发初始化恶意的类,那么就有可能将写文件漏洞转变为代码执行漏洞。
对于SpringBoot来说,FatJar会把所有资源打包进一个Jar文件内,因此无法再应用程序运行时往classpath等目录内写入文件,这也导致无法通过写Webshell、替换模版资源文件以及替换class文件等方式来实现RCE。
既然无法将文件写入应用程序的classpath目录中,那能否可以将文件写入更为底层的系统classpath目录(JDK HOME目录)中呢?
在Java程序运行时,JVM会通过类加载器来完成类的加载,将类文件加载到内存中。但JVM存在”懒加载”特性,懒加载机制的一个主要特征是相关.jar文件的Opened操作不会在程序一开始运行时就发生。换句话说,JVM不会在程序启动时就打开所有.jar文件来加载其中的类,只有当程序代码中真正调用到某个类时(例如通过new关键字创建对象、调用类的静态方法或访问类的静态字段等操作),JVM才会去打开包含该类的.jar文件,并加载该类到内存中。例如,如果程序中有一个java.util.ArrayList类的实例化操作,那么JVM会在执行到这行代码时,才去打开包含ArrayList类的.jar文件(通常是rt.jar或java.base模块中的某个.jar文件),并加载ArrayList类。
结合这个特性,可以通过增加或替换JDK HOME目录下的系统jar文件并主动触发jar文件里的类初始化操作来实现RCE的目的。需要注意的是,JDK在启动后,不会主动寻找JDK HOME目录下新增的jar文件去尝试加载,因此只能替换JDK HOME目录下原有的系统jar文件,并且还必须是系统启动后没有进行过Opened操作的系统jar文件。
除此以外,由于JDK HOME的目录路径一般是不固定的,可以通过枚举目录的方式来写入文件。
实现
Charset
Spring
在spring-web组件的org.springframework.web.accept.HeaderContentNegotiationStrategy类中,对于每一次请求,Spring框架都会尝试解析Accept头的值,并设置相应的字符集编码。
先跟进org.springframework.http.MediaType#parseMediaTypes方法,并再跟进几次调用到
spring-core组件org.springframework.util.MimeTypeUtils#parseMimeTypeInternal方法中,对获取到的Accept头进行解析,最终实例化一个MimeType对象。
跟进对应有参构造函数,利用checkParameters方法对parameters进行解析,接着调用Charset#forName方法来主动加载字符集的代码。
最后,结合上文分析的Spring框架解析Accept请求头来实现RCE目的。
Fastjson
在com.alibaba.fastjson.parser.ParserConfig类中有如下处理代码,而实际的findClass方法会枚举buckets中保存的IdentityHashMap<Type, ObjectDeserializer>类型键值对,java.nio.charset.Charset类名正好在白名单中,所以可以直接返回clazz。
1 | if (clazz == null) { |
1 | public Class findClass(String keyString) { |
在com.alibaba.fastjson.serializer.MiscCodec#deserialze方法中有以下处理代码,当clazz类型为java.nio.charset.Charset时,调用Charset#forName方法,最终调用到jre/lib/rt.jar!/sun/nio/cs/AbstractCharsetProvider.class中,其中this.packagePrefix值为sun.nio.cs.ext,即charsets.jar的包名前缀,并且参数可控,直接加载可控类即可触发RCE。
1 | if (clazz == Charset.class) { |
1 | private Charset lookup(String var1) { |
Jackson
JDBC
jre/classes
除了覆盖JDK8下的Bootstrap和Ext ClassLoader下的jar文件,还可以通过在/Library/Java/JavaVirtualMachines/jdkversion/Contents/Home/jre/classes目录(不存在就创建一个)下写入恶意class文件,通过Fastjson的expectClass绕过方式触发其加载。
例如,对于Fastjson 1.2.68可以编写一个实现了java.lang.AutoCloseable的恶意类,在其静态代码块中插入命令执行代码,绕过检查直接触发恶意类的加载,实现RCE。
1 | import java.io.IOException; |
1 | { |
SPI
跟进Charset.forName的源码,可以看到存在三个加载Charset的方法,跟进第三种方法,跟进其代码,很明显是一个SPI加载provider的模式。
可以通过编写一个继承了java.nio.charset.spi.CharsetProvider类的恶意provider,通过SPI机制,触发其加载并初始化。但需要注意的是,由于使用到的是系统类加载器,它对应的是Ext ClassLoader,理论上需要打包成jar包并放到jre/lib/ext内,并且根据类加载器缓存的机制,需要重启后才能加载到该jar包。
1 | import java.io.IOException; |
1 | # 目录结构 |
接着将Evil.class和SPI文件放到jre/classes目录后,重新启动SpringBoot项目,构造攻击请求即可。
1 | curl -X GET "http://127.0.0.1:18081/" -H "Accept: text/html;Charset=Evil" |
碎碎念
在实际场景中可能需要对服务器上的JDK目录进行爆破;
在文件上传漏洞中一般无法创建目录,由于classes目录通常需要攻击者自行创建,所以classes和spi的利用方式可能相对于直接覆盖charset.jar的方式来说可执行性较差。