thumbnail
Java 内存马(十一):JNDI 与反序列化植入入口

JNDI 和反序列化都不是内存马类型,它们只是可能取得初始代码执行的入口。真正的驻留仍然发生在 Filter、Servlet、Valve、Handler、Controller、线程或 JVM 字节码中。

1
2
3
4
5
6
外部输入
→ 解析或对象恢复
→ 代码执行
→ 获取容器或 JVM 上下文
→ 注册组件或修改字节码
→ 形成运行时驻留

只证明 lookup() 被调用,不能证明代码已经执行;只发现一个异常 Filter,也不能反推出入口一定是 JNDI。入口与驻留必须分别取证。

本文用于代码审计和应急分析,不提供远程利用服务、攻击载荷或命令执行代码。

一、入口与驻留为什么必须拆开

JNDI 到运行时驻留

一条完整链至少包含六个独立阶段:

阶段 需要证明的事实 主要证据
1. 输入进入 哪个参数、Header、消息或配置可控 原始请求、消息体、调用日志
2. 危险解析 输入是否到达 lookup 或反序列化 API 调用栈、断点、审计日志
3. 对象解析 Provider 返回什么,如何转换为对象 DNS/LDAP/RMI、ObjectFactory、过滤器日志
4. 代码执行 哪个类、方法或 Gadget 真正运行 类加载事件、线程栈、异常、JFR
5. 运行时注册 新对象挂入哪条请求链 Filter/Servlet/Handler/Mapping 注册表
6. 长期持有 谁持续引用它,是否存在复活点 堆引用、ClassLoader、线程、Agent、启动项

这六段不会必然全部出现。例如反序列化 Gadget 可能不经过 JNDI;JNDI lookup 也可能只返回一个普通数据对象;代码执行后可能只完成一次操作,没有注册任何驻留组件。

分析时应分别命名:

1
2
3
4
植入入口:最初如何获得执行能力
驻留位置:异常对象或字节码最终挂在哪里
触发条件:什么请求或事件会再次执行它
复活点:清除后什么机制能重新植入

删除驻留对象只能解决当前请求入口;修复反序列化漏洞也不会自动移除 JVM 中已经存在的 Handler 或已修改字节码。

二、JNDI lookup 后面到底发生什么

JNDI 是命名与目录服务的统一 API。InitialContext.lookup(name) 只是入口,实际行为由 URL Scheme、Naming Provider、返回对象和对象工厂共同决定。

1
2
3
4
5
6
lookup(name)
→ 根据名称选择 Provider
→ DNS / LDAP / RMI / 本地命名服务
→ 返回普通对象、Reference、序列化数据或其他结果
→ NamingManager 进行对象转换
→ 可能调用本地 ObjectFactory

常见分支:

返回结果 后续行为 关键限制
普通对象或属性 直接返回调用方 通常没有代码加载
Reference 解析工厂类与地址 工厂类来源、过滤规则、类加载器
本地 ObjectFactory 调用目标类路径中已有工厂 本地依赖是否存在、工厂过滤器
序列化对象 进入对象恢复过程 JDK 过滤器、Provider 与可用类
远程 Codebase 尝试取得外部类定义 现代 JDK 更新默认严格限制

因此,“关闭远程 Codebase”只切断其中一条分支,不代表本地 ObjectFactory、已有依赖链或危险业务工厂全部安全。真实结果取决于:

1
2
3
4
5
6
JDK 完整更新号
JNDI Provider 与协议
对象工厂过滤属性
反序列化过滤器
应用现有依赖
ClassLoader 与模块边界

代码审计不要只搜索字符串 ldap://。应从数据流反向寻找:

1
2
3
4
5
InitialContext.lookup
Context.lookup
DirContext.search / getAttributes
JMS、数据源、日志或框架对 JNDI 的封装
可控环境变量和命名配置

安全验证可以使用本机不可达地址,只确认是否发生 lookup 和出站尝试,不返回任何对象:

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
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.util.Hashtable;

public final class JndiObservationProbe {
public static void main(String[] args) {
String name = "ldap://127.0.0.1:1389/observation-only";

Hashtable<String, Object> environment = new Hashtable<>();
environment.put(
"com.sun.jndi.ldap.connect.timeout", "1000");
environment.put(
"com.sun.jndi.ldap.read.timeout", "1000");

try {
InitialContext context = new InitialContext(environment);
try {
context.lookup(name);
} finally {
context.close();
}
} catch (NamingException expected) {
System.out.println(expected.getClass().getName());
}
}
}

该探针预期在 1 秒左右连接失败,只用于在隔离环境观察 Socket、JFR 网络事件和调用栈。不要把任意外部地址带入生产 JVM。

三、反序列化与 JSON 类型恢复的真实边界

“反序列化”不只指 ObjectInputStream。只要解析器根据输入选择类型、创建对象并调用构造方法、Setter、readObject() 或其他回调,就可能形成从数据到代码的执行路径。

1
2
3
4
5
6
输入数据
→ 解析器选择目标类型
→ 创建对象
→ 设置属性或恢复对象图
→ 触发已有类的方法链
→ 到达危险副作用

需要区分三类机制:

类型 入口 主要控制点
Java 原生序列化 ObjectInputStream.readObject() ObjectInputFilter、允许类型、深度与大小
JSON 多态类型 @type、默认类型或类型元数据 解析器版本、AutoType/SafeMode、允许列表
框架对象绑定 Bean 属性、表达式、转换器 可绑定字段、目标类、转换回调

JDK 9 引入全局反序列化过滤机制,JDK 17 又增加上下文相关过滤能力,但过滤器必须真正配置并覆盖对应入口才有效。第三方 JSON 解析器有自己的类型策略,不能因为 JVM 配置了 jdk.serialFilter 就认为所有 JSON 多态解析都受控。

判断一条链是否成立,至少固定四组变量:

1
2
3
4
解析器:精确版本、调用 API、Feature 和类型策略
依赖:目标类及其传递依赖是否存在
JDK:更新号、模块、过滤器和 JNDI 策略
容器:中间件版本、ClassLoader、最终注册 API

排错也应沿数据流推进:

1
2
3
4
5
6
类型是否被选择
→ 对象是否创建
→ 哪个回调被调用
→ lookup 或其他副作用是否发生
→ 返回对象是否完成解析
→ 容器注册表是否真的变化

HTTP 500 只能证明链路某处失败,不能直接说明“JDK 太高”或“Tomcat 不兼容”。

四、从入口日志追到最终驻留

应急调查应把不同时间线对齐:

1
2
3
4
5
6
T0  外部请求或消息进入
T1 解析器选择类型或调用 lookup
T2 DNS / LDAP / RMI / HTTP 出站
T3 新类加载、ObjectFactory 或 Gadget 执行
T4 容器注册表、Handler 树或字节码发生变化
T5 新触发请求命中驻留组件

每个时间点应保存:

  • 原始请求、消息体和入口线程栈;
  • DNS、代理、防火墙及主机网络连接记录;
  • 类加载时间、ClassLoader、CodeSource 和 ProtectionDomain;
  • JNDI Provider、对象工厂与反序列化过滤器配置;
  • Servlet、Filter、Listener、Valve、Handler、Controller 和 Agent 清单;
  • 同制品可信节点在相同时间窗口的对照结果。

查杀不能停在封禁某个 LDAP 地址。攻击者可以更换地址,已注册对象也不会因网络阻断自动消失。完整处置顺序是:

1
2
3
4
5
6
隔离受影响节点
→ 保存输入、网络、类加载和堆证据
→ 修复解析或 JNDI 入口
→ 清除运行时驻留和复活点
→ 从可信制品重建
→ 重新核对请求链与出站行为

最终结论必须同时回答两个问题:入口为什么能执行,以及执行后把什么对象挂到了哪里。任何一项没有证据,调查都还没有闭环。

上一篇
下一篇