内存马检测与清除实操
前面各篇回答了”内存马怎么打“,这一篇回答”内存马怎么查、怎么清“。
内存马最难检测的根本原因,在 《内存马原理总纲》 里已经讲过:它用和正常组件完全相同的注册接口,把恶意代码塞进容器运行时注册表,磁盘上什么都没有。所以检测的思路必须反过来——不再看文件,而是看运行时的注册表和代码行为。
本文的清除方法按”能否热移除”分为两类:Filter / Servlet / Listener / Valve / Spring 型可热移除(无需重启);Agent 型无法热移除,只能重启 JVM。这是应急响应的关键判断。
一、检测的整体思路
| 检测视角 | 原理 | 覆盖类型 | 缺点 |
|---|---|---|---|
| 文件侧 | 扫磁盘 webshell | 传统 JSP webshell | 对内存马完全失效 |
| 注册表基线对比 | 枚举容器当前注册表,与启动基线 diff | Filter / Servlet / Listener / Valve / Spring / WebSocket | 需要提前有基线;Agent 型看不到 |
| 行为检测(RASP) | Hook addFilterDef / addValve / defineClass 等注册调用,判断调用栈是否来自启动阶段 |
全部类型(含 Agent 的 addTransformer / retransformClasses) |
需要部署 RASP,有误报 |
核心结论:单靠一个视角不够,实战是”注册表枚举 + 内存取证 + 行为监控”三管齐下。
二、按类型的检测特征与命令
以下命令以 Arthas 为主(在线、无需重启),
vmtool用于拿容器实例并访问其实例字段。各类型原理详见对应分篇。
2.1 总表
| 类型 | 恶意代码藏在哪 | 一句话判断特征 |
|---|---|---|
| Filter | filterConfigs / filterDefs / filterMaps |
出现非业务类名的 Filter |
| Servlet | children + servletMappings |
出现未声明的 Wrapper / 路径 |
| Listener | applicationEventListenersList |
出现非框架的 *Listener |
| Valve | 容器的 Pipeline |
出现非 Standard*Valve 的 Valve |
| Spring | adaptedInterceptors / mappingRegistry |
多出非业务拦截器 / 路由 |
| WebSocket | WsServerContainer 的 endpointPathMap |
出现非业务 Endpoint 路径 |
| Agent | 类字节码本身(无注册表) | 字节码与磁盘 .class 不一致 |
2.2 Filter
# 列出 StandardContext 的 filterConfigs(实例字段,先拿实例)
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].filterConfigs' --limit 10
# 搜索可疑 Filter 类
sc *.Filter*
判断:filterConfigs 里的 key(filterName)和对应的 filter 实例类名,是否与 web.xml / 注解 / 启动基线一致。类名是否来自非业务 jar、或干脆是个匿名/默认包类,是重要线索。
2.3 Servlet
# 看 children 里有没有未声明的 Wrapper
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].children' --limit 10
# 看 servletMappings 有没有可疑路径
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].servletMappings' --limit 10
判断:业务里没有的路径(如 /shell、/cmd/*)出现在 servletMappings,或 children 里出现陌生 Wrapper。
2.4 Listener
# 看监听器列表
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].applicationEventListenersList' --limit 10
# 搜索可疑 Listener
sc *.*Listener
判断:applicationEventListenersList 里出现非 Spring / 非业务框架的 *Listener(正常只有 Spring 的 ContextLoaderListener 之类的几个)。
2.5 Valve
# 列出管道上的所有 Valve
vmtool --action getInstances --className org.apache.catalina.core.StandardContext \
--express 'instances[0].getPipeline().getValves()' --limit 10
判断:正常只有 StandardContextValve / StandardHostValve 等内置阀;出现其他 Valve 子类即为可疑。注意也要看 Host / Engine 的 Pipeline(高级 Valve 马会挂在更上层)。
2.6 Spring
// 检测代码:列出所有拦截器与路由
WebApplicationContext ctx =
WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
for (Object interceptor : mapping.getAdaptedInterceptors()) {
System.out.println("interceptor: " + interceptor.getClass().getName());
}
for (Object handlerMethod : mapping.getHandlerMethods().keySet()) {
System.out.println("mapping: " + handlerMethod);
}
判断:出现非业务拦截器类;路由表里出现业务没有的路径(如 /evil)。
2.7 WebSocket
# 看注册的 Endpoint 路径
vmtool --action getInstances --className org.apache.tomcat.websocket.server.WsServerContainer \
--express 'instances[0].getEndpointPathMap()' --limit 5
判断:出现非业务路径(如 /ws)。
2.8 Agent(最难,注册表看不到)
Agent 型内存马不产生任何容器组件,只能两条路:
# 1. 反编译运行时字节码,与磁盘/源码对比
jad org.apache.catalina.core.ApplicationFilterChain
# 出现 Runtime.getRuntime().exec / 插入的变量名等即为 Agent 内存马
# 2. 枚举已注册的 ClassFileTransformer
# 用 attach 一个自写检测 agent,打印所有 transformer 的类名
三、检测工具链与使用时机
| 工具 | 用途 | 时机 |
|---|---|---|
Arthas(vmtool / sc / jad) |
在线枚举注册表、反编译字节码,不重启 | 第一现场,优先 |
| jmap dump + MAT / VisualVM | 离线内存取证,能挖到被引用但未注册的对象 | 现场不便操作 / 复盘 |
| JMX(jconsole / jcmd) | 看 MBean(Filter/Valve 会注册 MBean) | 已开启 JMX 时 |
| RASP | 实时拦截注册调用,判断调用栈 | 常态化防御,而非事后查 |
# 快速取证:dump 内存(先确认 PID)
jps -l
jmap -dump:live,format=b,file=heap.hprof <pid>
# MAT 里搜 FilterDef / ApplicationFilterConfig / ValveBase / AbstractTranslet 子类
建议流程:jps 确认进程 → Arthas vmtool 逐个枚举注册表 → 可疑项 sc 看类来源 → 必要时 jad 反编译 / jmap dump 留证。
四、清除实操(按类型)
总原则:内存马 = 注册进容器的一个条目,清除 = 把它从注册表撤销。 类型不同,撤销的 API 不同;多数可热移除,Agent 除外。
4.1 可热移除的类型
Filter——从 filterConfigs / filterDefs / filterMaps 三处撤掉:
StandardContext context = ...; // 反射链拿 context,见 Filter 篇
// 1. 先移除实例配置
context.removeFilterDef(findFilterDef("shell"));
// 2. 移除映射(filterMaps 在较新版本是包私有 ContextFilterMaps,需反射 remove)
Field mapsField = StandardContext.class.getDeclaredField("filterMaps");
mapsField.setAccessible(true);
Object filterMaps = mapsField.get(context);
Method remove = filterMaps.getClass().getMethod("remove", String.class);
remove.setAccessible(true);
remove.invoke(filterMaps, "shell");
// 3. 移除 filterConfigs 中的实例
Field cfField = StandardContext.class.getDeclaredField("filterConfigs");
cfField.setAccessible(true);
((Map<?, ?>) cfField.get(context)).remove("shell");
Servlet——移除 Wrapper 与映射:
Wrapper wrapper = (Wrapper) context.findChild("shell");
context.removeChild(wrapper); // 从 children 移除
context.removeServletMapping("/shell"); // 从 servletMappings 移除
Listener——一行:
// 遍历找到恶意 listener 实例后移除
context.removeApplicationEventListener(listener);
Valve——一行:
for (Valve valve : context.getPipeline().getValves()) {
if (valve.getClass().getName().contains("Evil")) {
context.getPipeline().removeValve(valve);
}
}
// 若挂在 Host / Engine,用同样的 getPipeline() 往上取再 removeValve
Spring——移除拦截器 / 反注册路由:
// Interceptor
List<HandlerInterceptor> interceptors = /* 反射拿 adaptedInterceptors */;
interceptors.remove(evilInterceptor);
// Controller 路由
mapping.unregisterMapping(info); // 反注册 /evil
4.2 无法热移除:Agent 型
Agent 型改的是已加载类的字节码,ClassFileTransformer 一旦生效,JVM 里没有”还原”接口。唯一的彻底清除是重启 JVM。
Agent 内存马
→ 枚举 transformer / 字节码 diff 确认(2.8)
→ 无法热移除
→ 备份取证后重启 JVM
→ 启动参数加 -XX:+DisableAttachMechanism,防止再次 attach
4.3 清除有效性对比
| 类型 | 能否热移除 | 清除后是否立即生效 |
|---|---|---|
| Filter | ✅ | 下一次请求构建 Filter 链时(立即) |
| Servlet | ✅ | 下一次路由时(立即) |
| Listener | ✅ | 下一个请求事件(立即) |
| Valve | ✅ | 下一个请求进容器(立即) |
| Spring | ✅ | 下一次分发(立即) |
| WebSocket | ✅ | 下次连接(立即) |
| Agent | ❌ | 必须重启 JVM |
五、应急处置流程
确认感染(发现可疑注册表条目 / 字节码 diff)
↓
① 隔离:断外网 / 摘负载,避免攻击者继续利用
↓
② 取证:Arthas 导出注册表快照 + jmap dump 内存 + 收集日志(留痕,勿先删)
↓
③ 清除:按第四节热移除;Agent 型重启 JVM
↓
④ 复检:清除后再跑一遍第二节的枚举,确认注册表干净
↓
⑤ 加固:修复反序列化 / JNDI 等 RCE 入口(事前补洞)
+ 部署 RASP 监控注册调用(事中)
+ 建立启动基线,周期性对比(事后)
关键提醒:先取证、后清除。热移除会把这个对象从内存里抹掉,若没先 dump 留证,攻击者代码就丢了,不利于溯源和复盘。
总结
| 类型 | 检测入口 | 清除手段 |
|---|---|---|
| Filter | filterConfigs 枚举 |
removeFilterDef + 反射移除 map |
| Servlet | children / servletMappings |
removeChild + removeServletMapping |
| Listener | applicationEventListenersList |
removeApplicationEventListener |
| Valve | getPipeline().getValves() |
removeValve |
| Spring | adaptedInterceptors / handlerMethods |
list.remove / unregisterMapping |
| WebSocket | endpointPathMap |
反注册 Endpoint |
| Agent | 字节码 diff / transformer 枚举 | 重启 JVM(唯一) |
三句话收尾:
- 内存马检测看注册表,不看文件——枚举容器当前注册表,与启动基线对比;
- 大部分可热移除,Agent 只能重启——判断类型决定处置方式;
- 先取证再清除,防御靠事前——RCE 入口不补,清完还会再进来。
延伸阅读:《内存马原理总纲》 《反序列化与 JNDI 无文件注入内存马》 各类型分篇的”检测方式 / 防御建议”章节:Filter Listener Valve Agent