Java 内存马检测与查杀实战:Tomcat 排查

适用场景

内存马检测与查杀针对的是不落地文件即可执行的 Web 后门。攻击者通过反序列化、文件上传解析、表达式注入或 Tomcat 管理端弱口令拿到一次代码执行机会后,会把恶意组件直接注册进 JVM 内存,磁盘上找不到任何 jsp 或 class 文件,常规的 webshell 目录扫描完全失效。当服务器出现以下信号时,应当启动内存马排查:

  • 磁盘目录无异常文件,但 access_log 中持续出现固定 UA 或固定路径的高频访问;
  • Tomcat 进程 CPU 长期偏高、Full GC 频率异常上升,但业务流量并未增长;
  • jstack 输出中出现业务代码之外的陌生线程名,或存在大量 WAITING 状态的代理线程;
  • 源码仓库与线上 class 文件比对后哈希不一致,但文件修改时间为原始时间。

前置条件

  • 一支与线上完全一致的 JDK(版本需与目标 JVM 一致,否则堆转储解析会出错);
  • Arthas 3.7 以上版本,已上传至服务器 /opt/tools/;
  • Eclipse MAT 或基于 OQL 的分析环境,用于离线分析 hprof;
  • 目标容器为 Tomcat 8.5 / 9.x,JDK 8 或 JDK 11,具备 jmap、jstack、jcmd 权限;
  • 已备份当前应用部署包,能随时执行滚动重启。

原理说明

Java 内存马按注册位置可分为三类,排查思路因此不同:

  • 组件型内存马:通过 ServletContext.addServlet / addFilter / addListener 动态注册,或在 Tomcat 中直接操作 StandardContext 的 filterMaps、children 集合。这类马在内存中表现为一个没有对应 web.xml 声明的组件实例。
  • Valve 型内存马:向 StandardHost 或 StandardEngine 的 pipeline 中插入自定义 Valve,请求会先经过该 Valve,隐蔽性最强,且不依赖 Servlet 规范。
  • Agent 型内存马:通过 premain / agentmain 挂载 javaagent,用 Instrumentation 改写字节码,把恶意逻辑织入正常的 Servlet 方法中。特征为 JVM 启动参数或运行时 /proc/<pid>/maps 中出现非白名单 jar。

三者的共同点是:恶意类加载器与业务类加载器不同,或组件的注册来源无法在源码中找到。因此检测的核心不是搜索关键字,而是比对内存中的组件清单与部署包中的声明清单。

操作步骤

步骤一:锁定进程并留存现场

# 定位 Tomcat 主进程
jps -lvm | grep -i catalina

# 留存线程快照,重点看非常规线程
jstack -l <PID> > /tmp/jstack_$(date +%s).txt
grep -E "^\"(http-nio|Catalina|ContainerBackgroundProcessor)" /tmp/jstack_*.txt | head -40

# 查看 JVM 启动参数中的 javaagent 与 -javaagent 挂载
tr '\0' '\n' < /proc/<PID>/cmdline | grep -i javaagent

# 查看运行时加载的非白名单 jar
ls -l /proc/<PID>/fd 2>/dev/null | grep -Ei "\.jar$" | grep -vE "tomcat|jdk|lib/|WEB-INF/lib"

若 cmdline 中出现未知的 -javaagent:/tmp/xxx.jar,即可直接判定为Agent 型内存马。

步骤二:用 Arthas 在线枚举可疑组件

# 启动 Arthas 并附加目标进程
java -jar /opt/tools/arthas-boot.jar <PID>

# 1) 列出所有自定义 ClassLoader(Tomcat 的正常加载器数量是固定的)
classloader -l

# 2) 查找实现 Filter / Servlet 接口的可疑类
sc -d * implements javax.servlet.Filter | grep -E "class-info|code-source"

# 3) 反编译实现细节,确认是否包含命令执行特征
jad --source-only com.example.web.SysFilter

# 4) 追踪可疑方法调用来源
stack com.example.web.SysFilter doFilter

正常的 Filter 一定能在 WEB-INF/web.xml 或 Spring 配置中找到声明来源;找不到声明的 Filter / Servlet 即为高度可疑对象。

步骤三:导出堆转储做离线比对

# 触发堆转储(生产环境注意磁盘与 STW 影响)
jmap -dump:live,format=b,file=/tmp/heap_$(date +%s).hprof <PID>

# 压缩后下载到分析机
gzip -9 /tmp/heap_*.hprof

在 Eclipse MAT 中打开 hprof,使用 OQL 直接检索动态注册的组件:

SELECT * FROM org.apache.catalina.core.StandardContext c
WHERE c.name != null

-- 检索所有 Value 实例,与 conf/server.xml 声明逐一比对
SELECT * FROM org.apache.catalina.Valve v

重点比对 children、filterMaps、pipeline 三个集合的元素数量与预期是否一致。

步骤四:核查静态配置文件

# 比对 web.xml 与容器级配置
diff <(xmllint --format /opt/tomcat/conf/server.xml) <(xmllint --format /opt/tomcat/conf/server.xml.bak)

# 确认是否存在被篡改的 Valve / Listener 声明
grep -nE "Valve|Listener|Filter" /opt/tomcat/conf/server.xml /opt/tomcat/conf/context.xml

# 校验部署包完整性(与仓库发行的 SHA256 清单比对)
cd /opt/tomcat/webapps/ROOT && find . -name "*.class" -newer WEB-INF/web.xml -print

步骤五:清除与恢复

# 1. 摘除流量:从负载均衡中下线该节点
# 2. 终止进程并清空临时目录
systemctl stop tomcat
rm -rf /tmp/*.jar /opt/tomcat/work/* /opt/tomcat/temp/*

# 3. 用仓库原始包覆盖部署目录
rsync -a --delete /opt/release/ROOT/ /opt/tomcat/webapps/ROOT/

# 4. 禁用高危管理端,禁止运行时挂载代理
sed -i 's/<Connector port="8009"/<!-- <Connector port="8009"/' /opt/tomcat/conf/server.xml
# 并在 JVM 参数中增加:
# -XX:+DisableAttachMechanism  (禁止外部 attach,阻断 Agent 型注入)

配置验证

# 验证 DisableAttachMechanism 已生效:应输出 "Unable to open socket file" 或 attach 失败
jcmd <PID> VM.version

# 复查自定义类加载器数量是否回到基线
classloader -l | wc -l

# 复查 Filter 清单,确认无未声明组件
sc -d * implements javax.servlet.Filter | grep "class-info" | wc -l

# 确认 access_log 中可疑 UA 已消失
awk '{print $NF}' /opt/tomcat/logs/localhost_access_log.$(date +%Y-%m-%d).txt | sort | uniq -c | sort -rn | head

验证通过的标准是:自定义类加载器数量、Filter / Servlet 数量与干净环境基线一致,且 jcmd 无法再被外部附加。

常见问题

FAQ 1:重启 Tomcat 后内存马就消失了吗?

不一定。重启只能清除纯内存态的组件型与 Valve 型内存马。若攻击者同时写入了落地文件(如 ROOT/WEB-INF/classes 下的 class)、篡改了 Tomcat 启动脚本、或植入了 JVM 参数级 -javaagent,重启后会再次注入。正确做法是重启前先导出堆转储留存证据,重启后立刻复查 cmdline、webapps 目录哈希与 conf 配置,三项一致才能判定清除完成。

FAQ 2:为什么 grep 全盘搜索后门关键字找不到任何东西?

因为内存马的核心特征就是磁盘无文件。类字节码只存在于 JVM 堆或 Metaspace 中,磁盘检索天然无效。可行的替代方式有三种:一是用 Arthas 的 sc / jad 从内存中反编译类;二是导出 hprof 后用 OQL 检索字符串常量;三是通过 jcmd <PID> GC.class_histogram 输出类直方图,与基线比对新增类。三种方式都绕开磁盘,直击内存。

FAQ 3:生产环境不允许重启,能否在线卸载内存马?

可以,但风险极高。使用 Arthas 的 redefine 命令可以把已加载的恶意类替换为无害实现,或通过 JMX 调用 StandardContext.removeFilterDef 把 Filter 定义移除。但 redefine 只能修改方法体,无法删除类、无法修改方法签名;Valve 与 Listener 的移除也可能留下悬挂引用导致请求 500。在线处置仅适用于争取取证时间,最终仍需一次完整重启与全量校验。

总结

内存马检测的关键在于把排查视角从「磁盘文件」切换到「内存组件清单」:先看 JVM 启动参数与 /proc/<pid>/fd 定位 Agent 型注入,再用 Arthas 枚举 ClassLoader 与 Filter / Servlet 实现,最后用 hprof + OQL 与配置文件做交叉比对。清除阶段必须坚持「先取证、再重启、后比对」的顺序,并通过 -XX:+DisableAttachMechanism 关闭运行时挂载通道,从源头阻断二次注入。日常应把自定义类加载器数量、Filter 数量、access_log 的 UA 分布做成基线,任何偏离都能在数分钟内被发现。