Java 内存马(十三):Agent、Instrumentation 与 JVM 层驻留

Servlet、Filter、Valve 和 Handler 都能在运行时对象图中找到注册位置。但 JVM 层内存马可能不增加任何 URL 映射,而是直接修改一个已经存在的正常类。

此时 web.xml、Filter 注册表和 Handler 树都可能完全正常,行为却已经改变。排查视角必须从“多了什么组件”升级为“哪些类在什么时候被谁修改”。

本文 Agent 只观察类加载并计算字节码哈希,Transformer 始终返回 null,不会修改目标类。

实验代码以 JDK 17 为编译基线,使用模块化 Transformer 方法签名和 HexFormat。JDK 8 需要改用旧版 transform() 签名与普通十六进制转换;JDK 21/25 可以直接运行,并额外观察 Agent 相关日志和启动参数。

一、Agent 进入 JVM 与只读观测

1.1 生命周期与能力边界

Java Agent 可以在两种时机进入 JVM:

1
2
启动时:-javaagent,入口是 premain()
运行时:动态 Attach,入口是 agentmain()

Agent 获得 Instrumentation 后,可以:

  • 枚举 JVM 已加载类;
  • 监听后续类定义;
  • 注册 ClassFileTransformer;
  • 在能力允许时重转换或重定义已有类;
  • 查询对象大小和类加载状态。

这是一套合法的可观测性和诊断能力,APM、覆盖率工具、RASP、Mock 框架都会使用。风险来自未经授权的 Agent 和字节码修改,不是 Instrumentation API 本身。

需要先明确一个限制:公开的 Instrumentation API 可以注册和移除“自己持有引用”的 Transformer,却没有列出 JVM 中全部 Transformer 的方法。因此后加载的观察 Agent 不能直接拿到未知 Transformer 对象,只能结合启动参数、Attach 记录、类加载事件和运行时字节码间接判断。

1.2 Agent 入口

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package lab.agent;

import java.lang.instrument.Instrumentation;
import java.util.concurrent.atomic.AtomicBoolean;

public final class ObservationAgent {
private static final AtomicBoolean STARTED = new AtomicBoolean();

private ObservationAgent() {
}

public static void premain(
String arguments,
Instrumentation instrumentation) {

start("premain", arguments, instrumentation);
}

public static void agentmain(
String arguments,
Instrumentation instrumentation) {

start("agentmain", arguments, instrumentation);
}

private static void start(
String entry,
String arguments,
Instrumentation instrumentation) {

if (!STARTED.compareAndSet(false, true)) {
System.out.println("[agent] already started");
return;
}

String prefix = arguments == null || arguments.isBlank()
? "lab/"
: arguments.replace('.', '/');

System.out.println("[agent] observation only, entry=" + entry);
System.out.println("[agent] prefix=" + prefix);

instrumentation.addTransformer(
new HashingTransformer(prefix),
false);

printLoadedClasses(instrumentation, prefix);
}

private static void printLoadedClasses(
Instrumentation instrumentation,
String internalPrefix) {

String binaryPrefix = internalPrefix.replace('/', '.');

for (Class<?> type : instrumentation.getAllLoadedClasses()) {
if (type.getName().startsWith(binaryPrefix)) {
System.out.println("[loaded] " + type.getName()
+ " loader=" + type.getClassLoader()
+ " modifiable="
+ instrumentation.isModifiableClass(type));
}
}
}
}

premain() 与 agentmain() 共用同一初始化路径,AtomicBoolean 防止同一 Agent 被重复加载后注册多个 Transformer。生产取证工具如果使用 agentmain(),必须把自身加载时间、JAR 哈希和新增类记录到案件时间线。

1.3 只计算哈希的 Transformer

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package lab.agent;

import java.lang.instrument.ClassFileTransformer;
import java.security.MessageDigest;
import java.security.ProtectionDomain;
import java.util.HexFormat;

public final class HashingTransformer implements ClassFileTransformer {
private final String prefix;

public HashingTransformer(String prefix) {
this.prefix = prefix;
}

@Override
public byte[] transform(
Module module,
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {

if (className == null || !className.startsWith(prefix)) {
return null;
}

try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String hash = HexFormat.of().formatHex(
digest.digest(classfileBuffer));

System.out.println("[class] " + className
+ " loader=" + loader
+ " redefine=" + (classBeingRedefined != null)
+ " sha256=" + hash);
} catch (Exception error) {
System.out.println("[agent-error] " + className
+ " " + error.getMessage());
}

// null 表示不修改字节码。
return null;
}
}

transform() 的返回值非常关键:

1
2
null        → 保持原始字节码
新的 byte[] → 使用修改后的字节码

观察型 Agent 应始终返回 null。如果一个未知 Transformer 对核心 Servlet、Controller、Handler 或认证类返回新字节码,就需要解释它的来源和修改目的。

1.4 打包 Agent

JAR Manifest:

1
2
3
4
5
Manifest-Version: 1.0
Premain-Class: lab.agent.ObservationAgent
Agent-Class: lab.agent.ObservationAgent
Can-Redefine-Classes: false
Can-Retransform-Classes: false

这里主动关闭重定义和重转换能力,减少实验 Agent 的权限。

启动测试应用:

1
java -javaagent:observation-agent.jar=lab. -jar demo-app.jar

预期输出:

1
2
3
[agent] observation only
[agent] prefix=lab/
[class] lab/DemoApplication ... sha256=...

不要把前缀留空后记录所有类。高频类加载会产生大量日志,也可能明显影响启动性能。

1.5 运行时 Attach 完整链路

动态 Attach 的控制端使用 jdk.attach 模块连接目标 JVM,再触发 Agent JAR 的 agentmain():

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
27
28
29
30
31
package lab.agent;

import com.sun.tools.attach.VirtualMachine;

import java.nio.file.Path;

public final class AttachLauncher {
private AttachLauncher() {
}

public static void main(String[] args) throws Exception {
if (args.length < 2 || args.length > 3) {
throw new IllegalArgumentException(
"usage: <pid> <agent-jar> [agent-args]");
}

String pid = args[0];
String agentJar = Path.of(args[1])
.toAbsolutePath()
.normalize()
.toString();
String agentArgs = args.length == 3 ? args[2] : "lab.";

VirtualMachine vm = VirtualMachine.attach(pid);
try {
vm.loadAgent(agentJar, agentArgs);
} finally {
vm.detach();
}
}
}

JDK 9 以上编译和运行时显式加入模块:

1
2
3
4
PID=2481
javac --add-modules jdk.attach -d out src/lab/agent/AttachLauncher.java
java --add-modules jdk.attach -cp attach-launcher.jar \
lab.agent.AttachLauncher "$PID" observation-agent.jar lab.

这条链会产生可观测状态:控制端进程、Attach socket、目标 JVM 动态 Agent 日志、Agent JAR 读取、新类加载和 Transformer 注册。JDK 21 默认对动态加载 Agent 发出警告;目标使用 -XX:+DisableAttachMechanism 或显式禁止动态 Agent 加载时,链路会在更早阶段失败。

该观察型 Agent 不请求重转换能力,所以 Attach 前已经加载的类只能枚举类对象,无法通过本 Transformer 回放原始定义字节。若扫描器为了取哈希主动调用 retransform,它已经从“只读观察”变成“修改运行时事件流”,必须在工具说明和案件时间线中明确记录。

二、字节码修改、重转换与 Hidden Class

2.1 字节码修改与组件注册的证据差异

容器组件型内存马通常表现为新增对象:

1
2
3
4
新增 Filter
新增 Valve
新增 Handler
新增 Controller 映射

Transformer 型驻留可能只修改现有类:

1
2
3
4
类名不变
URL 映射不变
对象数量不变
方法字节码改变

例如请求仍然进入原来的 Filter,但 Filter 方法已被重转换。只比较类名和注册表不会发现差异,必须比较运行时字节码、类加载事件、Transformer 和 Agent 来源。

2.2 重转换与重定义的区别

能力 作用 检测重点
Transformer 类定义或重转换时接收字节码 谁注册、过滤哪些类、返回了什么
Retransform 重新走支持重转换的 Transformer isRetransformClassesSupported()、调用栈、目标类
Redefine 直接用新定义替换已有类 Agent 能力声明、类哈希变化、JFR/JVM TI 事件

并非所有类都可修改:

1
2
3
4
5
6
7
for (Class<?> type : instrumentation.getAllLoadedClasses()) {
if (type.getName().startsWith("com.example.")) {
System.out.println(type.getName()
+ " modifiable="
+ instrumentation.isModifiableClass(type));
}
}

检测工具自身调用重转换时,也会改变现场。使用前要明确是否只读;如果不是只读,必须保存工具产生的全部变更。

2.3 Hidden Class 观测边界

Hidden Class 不能通过普通类名重新查找:

1
2
3
Class.forName()           → 找不到
ClassLoader.loadClass() → 找不到
普通类加载器缓存遍历 → 可能漏掉

Instrumentation.getAllLoadedClasses() 可以返回 Hidden Class,但 getInitiatedClasses() 不会返回它,普通类名查找同样不可用。查看隐藏栈帧需要:

1
2
-XX:+UnlockDiagnosticVMOptions
-XX:+ShowHiddenFrames

隐藏类是 Lambda、动态语言和框架的正常基础设施。判断异常时要结合定义线程、调用栈、字节码行为和持有引用,不能只看名称格式。

三、动态 Agent 信号与排查

3.1 JDK 21 及以后版本的信号

JDK 21 开始对动态加载 Agent 发出警告。是否允许加载仍应以目标 JVM 的实际参数和执行结果为准,不能只根据版本号推断。重点参数:

1
2
3
4
-XX:+EnableDynamicAgentLoading
-XX:-EnableDynamicAgentLoading
-Djdk.instrument.traceUsage
-XX:+DisableAttachMechanism

防守建议:

  1. 不需要动态 Agent 时,评估显式关闭动态加载;
  2. 不需要 Attach 诊断时,再评估关闭 Attach;
  3. 保留 JVM 标准错误输出;
  4. 监控未知 Agent JAR 和临时目录文件;
  5. 核对 APM、RASP 和诊断平台白名单;
  6. 在隔离副本使用 jdk.instrument.traceUsage 定位调用方。

关闭 Attach 会影响正常的 jcmd、诊断和应急能力,不能把安全选项当作无成本开关。

3.2 怎样发现未知 Agent

启动时 Agent

检查:

1
2
3
4
PID=2481
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" VM.dynlibs

核对 -javaagent、-agentlib、Agent JAR 哈希和动态库来源。

运行时 Agent

检查:

  • JDK 21 动态加载告警;
  • Attach 相关审计日志;
  • 临时目录新增 JAR;
  • JFR 类加载时间线;
  • 新增 ClassLoader;
  • Instrumentation API 使用栈;
  • APM/RASP 平台变更记录。

运行时字节码

将关键类的运行时字节码与可信制品比较。优先对象:

  • 认证与授权组件;
  • Filter、Servlet、Controller;
  • Tomcat Valve 与 Jetty/Undertow Handler;
  • 表达式、反序列化和脚本入口;
  • 文件、网络和进程调用封装类。

注意编译器、混淆器和构建参数会影响字节码,必须与同一制品或同版本干净节点比较。

四、Transformer 移除、字节码恢复与证据闭环

removeTransformer() 必须传入注册时的同一个 Transformer 对象。防守侧通常没有未知 Transformer 的引用;即使成功移除,也可能因并发类加载收到少量迟到回调,并且已经修改的类不会自动还原。完整恢复需要判断:

1
2
3
4
5
6
Transformer 是否仍注册
哪些类已经被修改
是否保存了可信原始字节码
能否安全 redefine 回去
Agent 是否还有线程、定时任务或其他引用
入口是否仍可再次 Attach

生产环境优先从可信制品重建 JVM,而不是在线拼接原始字节码。随后关闭初始入口、轮换凭据、核对集群节点并重新采集类哈希基线。

4.1 JVM 层证据链

完整排查顺序:

1
2
3
4
5
6
Servlet 注册表
→ 容器原生请求链
→ 框架路由
→ Agent 与 Transformer
→ 运行时字节码
→ Hidden Class 与类加载事件

走到这一步,判断内存马已经不再依赖某个固定类名。任何异常行为最终都需要一条执行路径、一个持有关系和一组实际字节码。把三者找出来,才是 JVM 层分析的核心。

上一篇
下一篇