假设代理日志显示某个异常 URL 持续返回 200,但业务 Controller 没有任何访问记录。目标是 Linux 上的 Tomcat,Java 进程 PID 为 2481。
不要先重启。重启会让内存对象消失,也会销毁请求链、ClassLoader、动态字节码和对象持有关系。正确顺序只有一条:
1 | 摘流保进程 → 固定现场 → 定位运行时对象 → 追查植入入口 |
排查过程中始终回答四个问题:
- 请求通过什么映射或处理链到达可疑对象;
- 谁在长期持有这个对象;
- JVM 中实际执行的字节码来自哪里;
- 当前对象清除后,什么机制能把它再次写回来。
一、现场保全:先摘流,再固定 JVM
1.1 建立证据目录
先从负载均衡、Ingress 或反向代理摘除可疑节点,保留管理网络,并限制异常出站。随后执行:
1 | export PID=2481 |
environ.txt、线程栈和堆转储可能包含口令、Token、Session 与业务数据,必须作为敏感证据保存。
1.2 按影响程度采集 JVM
优先使用目标 JVM 同版本附带的工具,并以目标进程所属用户执行:
1 | JAVA_EXE="$(readlink -f "/proc/$PID/exe")" |
第一轮只采集启动状态、类加载器和线程:
1 | "$JCMD" "$PID" VM.version > "$CASE/vm-version.txt" |
立即筛选启动级驻留与可疑线程:
1 | grep -Ein -- '-javaagent|-agentlib|add-opens|add-exports|DisableAttachMechanism' \ |
线程栈只代表采集瞬间。没有命中,不能排除只在特定请求到达时执行的内存马。
节点已经摘流、磁盘空间足够且能接受停顿时,再采集类直方图和堆:
1 | "$JCMD" "$PID" GC.class_histogram > "$CASE/class-histogram.txt" |
堆转储可能触发 Full GC。超大堆和低延迟系统必须先评估停顿与磁盘空间,不能机械执行。
如果 JVM 已经存在持续 JFR,使用 JFR.check 找到录制名称并导出:
1 | "$JCMD" "$PID" JFR.check > "$CASE/jfr-check.txt" |
没有持续录制时,可以启动短时 JFR,但它只能观察启动录制之后的活动,无法还原已经完成的类加载:
1 | "$JCMD" "$PID" JFR.start \ |
等录制完成、所有文件停止写入后,再生成证据哈希:
1 | find "$CASE" -type f ! -name SHA256SUMS -print0 \ |
二、定位内存马:请求可达、对象持有、字节码来源
2.1 先判断问题位于请求链哪一层
Java Web 请求可以按以下顺序排查:
1 | 反向代理 |
日志缺口可以缩小范围:
| 日志现象 | 优先检查 |
|---|---|
| 代理有记录,Tomcat AccessLog 没有 | 代理转发、Connector、Engine/Host Valve |
| AccessLog 有记录,应用 Filter 没有 | Context Valve、容器级 Filter、异常 Servlet |
| Filter 有记录,Controller 没有 | Servlet 映射、Spring Interceptor、HandlerMapping |
| Controller 正常,但响应被替换 | 响应包装、后置 Filter、Valve、Agent 字节码修改 |
日志只能定位层级,不能单独证明内存马存在。最终必须看到运行时对象和持有关系。
2.2 用 MAT 还原 Tomcat 运行时对象
用 Eclipse MAT 离线打开 java-process.hprof,先查找所有 StandardContext:
1 | SELECT * FROM INSTANCEOF org.apache.catalina.core.StandardContext |
根据 path、docBase、父容器和 ClassLoader 找到目标应用,然后沿以下字段展开:
1 | StandardContext |
Tomcat 中三类主要可达关系是:
1 | FilterMap → Filter Name → FilterDef → ApplicationFilterConfig → Filter |
只发现一个 Filter 实例不能定性。只有映射、定义、实例和调用链闭合,它才可能接收请求。对可疑对象执行 Path to GC Roots,排除弱引用,并记录:
1 | 组件名称与类型 |
还可以从接口实现反向搜索:
1 | SELECT * FROM INSTANCEOF javax.servlet.Filter |
Tomcat 10 及以上将 Servlet API 包名改为 jakarta.servlet.*。攻击者也可能通过代理或继承间接进入请求链,因此接口查询不能代替 StandardContext 持有关系分析。
2.3 判断磁盘篡改还是纯内存修改
JAR 哈希只回答一个问题:承载可疑类的磁盘文件是否偏离发布基线。如果 CI/CD 或制品仓库保存了该构建的 SHA-256,可以直接比较:
这里检查的不一定是启动 JAR,而是可疑类对应的磁盘 JAR;文件被替换后通常要等重启、重部署或类重新加载才会生效。
1 | JAR_FROM_CODESOURCE="$CATALINA_BASE/webapps/app/WEB-INF/lib/runtime.jar" |
JAR_FROM_CODESOURCE 必须替换为 ProtectionDomain、CodeSource 或 Arthas sc -d 显示的实际路径;EXPECTED_SHA256 必须来自同版本、同构建的发布清单。没有可信哈希就跳过此项,不能拿不同版本的 JAR 强行比较。
磁盘一致也不能排除内存马。CodeSource 只说明类最初来自哪里,Agent 仍可在加载后重写字节码。因此下一步是导出 JVM 当前实际执行的类。
基础证据完成后,可在隔离节点使用经过内部校验的 Arthas:
1 | java -jar arthas-boot.jar "$PID" |
进入控制台后执行:
1 | sc -d com.example.runtime.DynamicFilter |
退出后保存原始类文件哈希:
1 | find /var/tmp/ir-java-bytecode -type f -name '*.class' \ |
判断方式:
| 磁盘 JAR | JVM 字节码 | 优先方向 |
|---|---|---|
| 异常 | 异常 | 制品、热部署目录或供应链被篡改 |
| 正常 | 异常 | Agent、Transformer、动态类定义 |
| 无来源 | 仅 JVM 存在 | 动态代理、脚本、Lambda、Hidden Class 或内存加载 |
CodeSource == null 不是恶意结论,必须结合请求可达关系与行为判断。
2.4 专用工具如何使用
| 工具 | 用途 | 侵入性 | 适合阶段 |
|---|---|---|---|
jcmd、JFR |
JVM 状态、堆、时间线 | 取决于命令 | 第一轮取证 |
| Eclipse MAT | 离线对象与 GC Root 分析 | 无 | 首选定位 |
| Arthas | 类来源、反编译、字节码导出 | Attach | 基础证据之后 |
| CopAgent | 提取运行 JVM 中的可疑类 | Attach | 样本确认 |
| SuAgent | 扫描 Servlet API 型内存马 | Attach、字节码修改 | 隔离节点验证 |
java-memshell-scanner |
Tomcat Servlet、Filter、Listener 辅助查杀 | 写入 JSP | 隔离副本或最后止血 |
SuAgent 的基本使用方式:
1 | java -jar suagent-loader.jar |
专用工具通常依赖已知接口、注册表或字节码特征。没有告警不能排除 Valve、Handler、Spring 路由、Agent 型驻留和未知变种。
java-memshell-scanner 会向 Web 应用写入一个具备运行时操作能力的 JSP。生产取证阶段不要直接部署;只有完成堆、线程、JFR 和文件哈希采集,且节点已隔离时,才考虑作为辅助处置工具。
其他中间件按相同原理检查不同持有者:
| 中间件 | 运行时持有者 | 首选获取方式 |
|---|---|---|
| Jetty | Server 与 Handler 树 |
JMX、dump、堆分析 |
| Undertow / WildFly | Root Handler、Deployment、Undertow 子系统 | JBoss CLI、堆分析 |
| WebLogic | WebApp Runtime、Deployment、Domain 配置 | Runtime MBean、管理审计 |
| WebSphere | Cell、Node、Server 与 Web Container | 管理面、Node Sync、堆分析 |
| Resin | WebApp 与 Filter/Servlet Mapping | 管理配置、堆分析 |
WildFly 可以直接导出管理面与运行时状态:
1 | DEPLOYMENT="app.war" |
管理面说明“按配置应该有什么”,堆和运行时对象证明“JVM 实际有什么”。两者不一致,就是高优先级调查点。
三、查杀:切断入口、运行时链和复活点
3.1 先确认处置对象
第二章定位到可疑对象后,先形成一条完整记录:
1 | 组件类型与名称 |
根据证据选择处置路线:
| 结果 | 处置 |
|---|---|
| 仅发现异常运行时对象 | 摘流后重建;无法重建时在线脱链 |
存在未知 -javaagent 或启动项 |
清理启动入口后重建 JVM |
| WAR、JAR、共享库或镜像异常 | 废弃当前制品,从可信源重新构建 |
| 入口尚未确认 | 保持隔离,不能直接恢复流量 |
3.2 清除入口和复活点
先从 JVM 属性取得 Tomcat 工作目录:
1 | CATALINA_BASE="$(sed -n 's/^catalina.base=//p' \ |
保存 systemd 信息,并根据 status 或 /proc/$PID/cgroup 确认真实 unit 名:
1 | systemctl status "$PID" > "$CASE/systemd-status.txt" 2>&1 |
根据最早异常请求、JFR、文件修改时间和管理面变更确定植入窗口,把代理日志、应用日志、WAF、身份认证和发布记录按时间排序。重点检查:
- 反序列化、JNDI、文件上传、表达式注入等代码执行入口;
- Tomcat Manager、JMX、应用服务器管理 API 和弱口令登录;
- CI/CD、制品仓库、部署脚本和运维账号;
-javaagent、热部署目录、共享库与定时拉起任务。
容器环境还要保存:
1 | CONTAINER="app-container" |
重点核对 image digest、command、args、环境变量、initContainer、sidecar、ConfigMap 和挂载卷。发现异常后,按以下顺序处置:
- 修补代码执行漏洞,关闭暴露的管理面;
- 删除未知 Agent、启动参数、调度任务和异常制品;
- 修复镜像、部署清单和 CI/CD;
- 轮换可能泄露的账号、Token、证书与 Session;
- 再处理当前 JVM 中的驻留对象。
3.3 重建优先,在线脱链兜底
默认使用可信制品替换整个节点:
- 从可信提交和固定 image digest 重新构建;
- 不复制可疑节点的工作目录、临时目录和展开后的应用;
- 在新节点重新采集请求链和类加载器基线;
- 验证通过后加入负载均衡;
- 保存证据后销毁受影响节点。
业务暂时无法重建时,才考虑在线脱链。以下代码只适合已经隔离、完成取证且具备回滚条件的节点,并应集成进批准的诊断 Agent,不能临时写成 JSP 上传到生产环境。
Tomcat Filter 先删除映射,再删除定义:
1 | static void detachFilter(StandardContext context, String filterName) { |
Tomcat Servlet 先删除全部 URL 映射,再移除 Wrapper:
1 | static void detachServlet(StandardContext context, String servletName) { |
Valve 必须根据堆取证得到的对象身份精确移除:
1 | static void detachValve(StandardContext context, Valve target) { |
相关类型:
1 | import java.util.Arrays; |
Spring MVC 路由使用公开 API 注销:
1 | static void detachSpringMapping( |
相关类型:
1 | import java.util.ArrayList; |
Listener 没有适合所有版本的安全动态注销接口。Agent 型内存马即使调用 removeTransformer(),已经修改的类也不会自动恢复。Jetty、Undertow、Listener 和 Agent 场景优先重建 JVM,不要直接反射修改私有集合或 Handler 的 next 指针。
在线脱链后立即重新枚举 FilterMap、Servlet Mapping、Pipeline 和 Spring Mapping,并使用无害请求确认可疑路径已不可达。对象可能因线程或缓存暂时留在堆中,不能把“仍有实例”直接等同于“仍能触发”。在线脱链只负责止血,最终仍应重建节点。
四、恢复验收:证明它不会再次出现
恢复节点必须使用新的 PID 和证据目录,不能覆盖受影响节点的原始证据:
1 | export PID=3512 # 替换为恢复节点的新 Java PID |
对正常节点和恢复节点发送同一组无害请求:
1 | BASE_URL="http://127.0.0.1:8080" |
响应包含时间戳或随机值时,不要直接比较整个响应体哈希,应比较状态码、关键响应头和稳定语义字段。同时确认代理、Tomcat AccessLog、应用 Filter 和 Controller 都按预期记录请求。
重新采集恢复节点状态:
1 | "$JCMD" "$PID" VM.command_line > "$VERIFY_CASE/command-line.txt" |
最终检查:
1 | [ ] 初始漏洞或管理面入口已经修复 |
APM、RASP、Byte Buddy、CGLIB、JSP 编译器、OSGi、热部署和合法全局 Filter 都可能改变类或请求链。单个随机类名、空 CodeSource 或一条反射告警不足以定性;结论必须由请求可达、对象持有、来源异常和行为异常共同支撑。
内存对象在重启后会消失,但植入漏洞、恶意制品、启动参数、被盗凭据和受控集群节点不会自动恢复。只有这些复活条件全部被清除,事件才真正结束。