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 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()); }
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
|
防守建议:
- 不需要动态 Agent 时,评估显式关闭动态加载;
- 不需要 Attach 诊断时,再评估关闭 Attach;
- 保留 JVM 标准错误输出;
- 监控未知 Agent JAR 和临时目录文件;
- 核对 APM、RASP 和诊断平台白名单;
- 在隔离副本使用
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;
- 表达式、反序列化和脚本入口;
- 文件、网络和进程调用封装类。
注意编译器、混淆器和构建参数会影响字节码,必须与同一制品或同版本干净节点比较。
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 层分析的核心。