Java 内存马(十二):应急响应与查杀实战

假设代理日志显示某个异常 URL 持续返回 200,但业务 Controller 没有任何访问记录。目标是 Linux 上的 Tomcat,Java 进程 PID 为 2481。

不要先重启。重启会让内存对象消失,也会销毁请求链、ClassLoader、动态字节码和对象持有关系。正确顺序只有一条:

1
2
摘流保进程 → 固定现场 → 定位运行时对象 → 追查植入入口
→ 清除驻留与复活点 → 可信重建 → 重新验证

排查过程中始终回答四个问题:

  1. 请求通过什么映射或处理链到达可疑对象;
  2. 谁在长期持有这个对象;
  3. JVM 中实际执行的字节码来自哪里;
  4. 当前对象清除后,什么机制能把它再次写回来。

一、现场保全:先摘流,再固定 JVM

1.1 建立证据目录

先从负载均衡、Ingress 或反向代理摘除可疑节点,保留管理网络,并限制异常出站。随后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export PID=2481
export CASE="/var/tmp/ir-java-$(hostname)-$(date -u +%Y%m%dT%H%M%SZ)"

umask 077
mkdir -p "$CASE"

date -u --iso-8601=seconds > "$CASE/time-utc.txt"
hostnamectl > "$CASE/hostname.txt" 2>&1
uname -a > "$CASE/uname.txt"
ps -p "$PID" -o pid,ppid,user,lstart,etime,%cpu,%mem,args \
> "$CASE/process.txt"
readlink -f "/proc/$PID/exe" > "$CASE/java-exe.txt"
tr '\0' ' ' < "/proc/$PID/cmdline" > "$CASE/cmdline.txt"
tr '\0' '\n' < "/proc/$PID/environ" > "$CASE/environ.txt"
ss -plant > "$CASE/network.txt"
lsof -nP -p "$PID" > "$CASE/open-files.txt" 2>&1

environ.txt、线程栈和堆转储可能包含口令、Token、Session 与业务数据,必须作为敏感证据保存。

1.2 按影响程度采集 JVM

优先使用目标 JVM 同版本附带的工具,并以目标进程所属用户执行:

1
2
3
4
5
JAVA_EXE="$(readlink -f "/proc/$PID/exe")"
JCMD="$(dirname "$JAVA_EXE")/jcmd"

"$JCMD" -l
"$JCMD" "$PID" help > "$CASE/jcmd-help.txt"

第一轮只采集启动状态、类加载器和线程:

1
2
3
4
5
6
7
"$JCMD" "$PID" VM.version > "$CASE/vm-version.txt"
"$JCMD" "$PID" VM.command_line > "$CASE/vm-command-line.txt"
"$JCMD" "$PID" VM.flags > "$CASE/vm-flags.txt"
"$JCMD" "$PID" VM.system_properties > "$CASE/vm-properties.txt"
"$JCMD" "$PID" VM.dynlibs > "$CASE/vm-dynlibs.txt"
"$JCMD" "$PID" VM.classloader_stats > "$CASE/classloaders.txt"
"$JCMD" "$PID" Thread.print -l > "$CASE/threads.txt"

立即筛选启动级驻留与可疑线程:

1
2
3
4
5
grep -Ein -- '-javaagent|-agentlib|add-opens|add-exports|DisableAttachMechanism' \
"$CASE/vm-command-line.txt" "$CASE/vm-flags.txt"

grep -Ein 'ProcessBuilder|Runtime|defineClass|ClassLoader|Transformer|Socket' \
"$CASE/threads.txt"

线程栈只代表采集瞬间。没有命中,不能排除只在特定请求到达时执行的内存马。

节点已经摘流、磁盘空间足够且能接受停顿时,再采集类直方图和堆:

1
2
3
"$JCMD" "$PID" GC.class_histogram > "$CASE/class-histogram.txt"
"$JCMD" "$PID" help GC.heap_dump > "$CASE/heap-dump-help.txt"
"$JCMD" "$PID" GC.heap_dump "$CASE/java-process.hprof"

堆转储可能触发 Full GC。超大堆和低延迟系统必须先评估停顿与磁盘空间,不能机械执行。

如果 JVM 已经存在持续 JFR,使用 JFR.check 找到录制名称并导出:

1
2
3
4
5
6
"$JCMD" "$PID" JFR.check > "$CASE/jfr-check.txt"

RECORDING_NAME="recording-name-from-jfr-check"
"$JCMD" "$PID" JFR.dump \
name="$RECORDING_NAME" \
filename="$CASE/existing-recording.jfr"

没有持续录制时,可以启动短时 JFR,但它只能观察启动录制之后的活动,无法还原已经完成的类加载:

1
2
3
4
5
"$JCMD" "$PID" JFR.start \
name=memshell-response \
settings=profile \
duration=10m \
filename="$CASE/memshell-response.jfr"

等录制完成、所有文件停止写入后,再生成证据哈希:

1
2
3
find "$CASE" -type f ! -name SHA256SUMS -print0 \
| sort -z \
| xargs -0 sha256sum > "$CASE/SHA256SUMS"

二、定位内存马:请求可达、对象持有、字节码来源

2.1 先判断问题位于请求链哪一层

Java Web 请求可以按以下顺序排查:

1
2
3
4
5
反向代理
→ Connector
→ Engine / Host / Context Pipeline
→ Servlet FilterChain
→ Servlet 或 Spring HandlerMapping

日志缺口可以缩小范围:

日志现象 优先检查
代理有记录,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
2
3
4
5
6
7
StandardContext
├─ filterMaps URL 与 Filter Name
├─ filterDefs Filter 定义
├─ filterConfigs 已初始化 Filter 与实例
├─ children StandardWrapper / Servlet
├─ applicationEventListenersList Listener
└─ pipeline → first → next Context Valve 链

Tomcat 中三类主要可达关系是:

1
2
3
FilterMap → Filter Name → FilterDef → ApplicationFilterConfig → Filter
URL Pattern → Servlet Name → StandardWrapper → Servlet
Pipeline.first → Valve.next → 下一个 Valve

只发现一个 Filter 实例不能定性。只有映射、定义、实例和调用链闭合,它才可能接收请求。对可疑对象执行 Path to GC Roots,排除弱引用,并记录:

1
2
3
4
5
6
7
组件名称与类型
URL 映射或链中位置
实现类
ClassLoader
CodeSource
GC Root 与持有路径
正常节点是否存在

还可以从接口实现反向搜索:

1
2
3
4
SELECT * FROM INSTANCEOF javax.servlet.Filter
SELECT * FROM INSTANCEOF javax.servlet.Servlet
SELECT * FROM INSTANCEOF javax.servlet.ServletRequestListener
SELECT * FROM INSTANCEOF org.apache.catalina.Valve

Tomcat 10 及以上将 Servlet API 包名改为 jakarta.servlet.*。攻击者也可能通过代理或继承间接进入请求链,因此接口查询不能代替 StandardContext 持有关系分析。

2.3 判断磁盘篡改还是纯内存修改

JAR 哈希只回答一个问题:承载可疑类的磁盘文件是否偏离发布基线。如果 CI/CD 或制品仓库保存了该构建的 SHA-256,可以直接比较:

这里检查的不一定是启动 JAR,而是可疑类对应的磁盘 JAR;文件被替换后通常要等重启、重部署或类重新加载才会生效。

1
2
3
4
5
6
7
8
9
JAR_FROM_CODESOURCE="$CATALINA_BASE/webapps/app/WEB-INF/lib/runtime.jar"
EXPECTED_SHA256="从发布清单取得的真实 SHA-256"
ACTUAL_SHA256="$(sha256sum "$JAR_FROM_CODESOURCE" | awk '{print $1}')"

if [ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ]; then
echo "磁盘 JAR 与发布基线一致"
else
echo "磁盘 JAR 偏离发布基线,需要保存样本并调查制品篡改"
fi

JAR_FROM_CODESOURCE 必须替换为 ProtectionDomain、CodeSource 或 Arthas sc -d 显示的实际路径;EXPECTED_SHA256 必须来自同版本、同构建的发布清单。没有可信哈希就跳过此项,不能拿不同版本的 JAR 强行比较。

磁盘一致也不能排除内存马。CodeSource 只说明类最初来自哪里,Agent 仍可在加载后重写字节码。因此下一步是导出 JVM 当前实际执行的类。

基础证据完成后,可在隔离节点使用经过内部校验的 Arthas:

1
java -jar arthas-boot.jar "$PID"

进入控制台后执行:

1
2
3
4
sc -d com.example.runtime.DynamicFilter
classloader -t
jad --source-only com.example.runtime.DynamicFilter
dump -d /var/tmp/ir-java-bytecode com.example.runtime.DynamicFilter

退出后保存原始类文件哈希:

1
2
find /var/tmp/ir-java-bytecode -type f -name '*.class' \
-exec sha256sum {} \;

判断方式:

磁盘 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
2
3
java -jar suagent-loader.jar
java -jar suagent-loader.jar attach "$PID"
java -jar suagent-loader.jar detach "$PID"

专用工具通常依赖已知接口、注册表或字节码特征。没有告警不能排除 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
2
3
4
5
DEPLOYMENT="app.war"
jboss-cli.sh --connect \
--commands="/subsystem=undertow:read-resource(recursive=true, include-runtime=true)"
jboss-cli.sh --connect \
--commands="/deployment=$DEPLOYMENT:read-resource(recursive=true, include-runtime=true)"

管理面说明“按配置应该有什么”,堆和运行时对象证明“JVM 实际有什么”。两者不一致,就是高优先级调查点。

三、查杀:切断入口、运行时链和复活点

3.1 先确认处置对象

第二章定位到可疑对象后,先形成一条完整记录:

1
2
3
4
5
6
7
组件类型与名称
URL 映射或请求链位置
实现类、ClassLoader、CodeSource
容器持有路径
最早异常时间
可能的植入入口
可能的复活点

根据证据选择处置路线:

结果 处置
仅发现异常运行时对象 摘流后重建;无法重建时在线脱链
存在未知 -javaagent 或启动项 清理启动入口后重建 JVM
WAR、JAR、共享库或镜像异常 废弃当前制品,从可信源重新构建
入口尚未确认 保持隔离,不能直接恢复流量

3.2 清除入口和复活点

先从 JVM 属性取得 Tomcat 工作目录:

1
2
3
4
5
6
7
CATALINA_BASE="$(sed -n 's/^catalina.base=//p' \
"$CASE/vm-properties.txt" | head -n 1)"

test -d "$CATALINA_BASE" || {
echo "未识别到有效的 catalina.base"
exit 1
}

保存 systemd 信息,并根据 status 或 /proc/$PID/cgroup 确认真实 unit 名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
systemctl status "$PID" > "$CASE/systemd-status.txt" 2>&1
cat "/proc/$PID/cgroup" > "$CASE/cgroup.txt"

SERVICE="tomcat.service" # 替换为现场确认的 unit
systemctl cat "$SERVICE" > "$CASE/systemd-unit.txt" 2>&1

grep -RInE -- '-javaagent|-agentlib|add-opens|add-exports' \
/etc/systemd/system \
/usr/lib/systemd/system \
"$CATALINA_BASE/bin" \
2>/dev/null > "$CASE/startup-search.txt"

find "$CATALINA_BASE" \
-type f \( -name '*.jar' -o -name '*.class' -o -name '*.xml' -o -name '*.sh' \) \
-printf '%TY-%Tm-%TdT%TH:%TM:%TS %s %p\n' \
| sort > "$CASE/tomcat-files.txt"

根据最早异常请求、JFR、文件修改时间和管理面变更确定植入窗口,把代理日志、应用日志、WAF、身份认证和发布记录按时间排序。重点检查:

  • 反序列化、JNDI、文件上传、表达式注入等代码执行入口;
  • Tomcat Manager、JMX、应用服务器管理 API 和弱口令登录;
  • CI/CD、制品仓库、部署脚本和运维账号;
  • -javaagent、热部署目录、共享库与定时拉起任务。

容器环境还要保存:

1
2
3
4
5
6
7
8
CONTAINER="app-container"
POD="app-pod"
NAMESPACE="production"

docker inspect "$CONTAINER" > "$CASE/docker-inspect.json"
docker diff "$CONTAINER" > "$CASE/docker-diff.txt"
kubectl get pod "$POD" -n "$NAMESPACE" -o yaml > "$CASE/pod.yaml"
kubectl describe pod "$POD" -n "$NAMESPACE" > "$CASE/pod-describe.txt"

重点核对 image digest、command、args、环境变量、initContainer、sidecar、ConfigMap 和挂载卷。发现异常后,按以下顺序处置:

  1. 修补代码执行漏洞,关闭暴露的管理面;
  2. 删除未知 Agent、启动参数、调度任务和异常制品;
  3. 修复镜像、部署清单和 CI/CD;
  4. 轮换可能泄露的账号、Token、证书与 Session;
  5. 再处理当前 JVM 中的驻留对象。

3.3 重建优先,在线脱链兜底

默认使用可信制品替换整个节点:

  1. 从可信提交和固定 image digest 重新构建;
  2. 不复制可疑节点的工作目录、临时目录和展开后的应用;
  3. 在新节点重新采集请求链和类加载器基线;
  4. 验证通过后加入负载均衡;
  5. 保存证据后销毁受影响节点。

业务暂时无法重建时,才考虑在线脱链。以下代码只适合已经隔离、完成取证且具备回滚条件的节点,并应集成进批准的诊断 Agent,不能临时写成 JSP 上传到生产环境。

Tomcat Filter 先删除映射,再删除定义:

1
2
3
4
5
6
7
8
9
10
11
12
static void detachFilter(StandardContext context, String filterName) {
for (FilterMap map : context.findFilterMaps()) {
if (filterName.equals(map.getFilterName())) {
context.removeFilterMap(map);
}
}

FilterDef definition = context.findFilterDef(filterName);
if (definition != null) {
context.removeFilterDef(definition);
}
}

Tomcat Servlet 先删除全部 URL 映射,再移除 Wrapper:

1
2
3
4
5
6
7
8
9
10
11
12
static void detachServlet(StandardContext context, String servletName) {
for (String pattern : context.findServletMappings()) {
if (servletName.equals(context.findServletMapping(pattern))) {
context.removeServletMapping(pattern);
}
}

Container child = context.findChild(servletName);
if (child != null) {
context.removeChild(child);
}
}

Valve 必须根据堆取证得到的对象身份精确移除:

1
2
3
4
5
6
7
8
9
10
static void detachValve(StandardContext context, Valve target) {
boolean registered = Arrays.stream(context.getPipeline().getValves())
.anyMatch(valve -> valve == target);

if (!registered) {
throw new IllegalStateException("target valve is not in this pipeline");
}

context.getPipeline().removeValve(target);
}

相关类型:

1
2
3
4
5
6
import java.util.Arrays;
import org.apache.catalina.Container;
import org.apache.catalina.Valve;
import org.apache.catalina.core.StandardContext;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;

Spring MVC 路由使用公开 API 注销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void detachSpringMapping(
RequestMappingHandlerMapping mapping,
String handlerClass,
String handlerMethod) {

List<RequestMappingInfo> targets = new ArrayList<>();

mapping.getHandlerMethods().forEach((info, method) -> {
if (method.getBeanType().getName().equals(handlerClass)
&& method.getMethod().getName().equals(handlerMethod)) {
targets.add(info);
}
});

targets.forEach(mapping::unregisterMapping);
}

相关类型:

1
2
3
4
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

Listener 没有适合所有版本的安全动态注销接口。Agent 型内存马即使调用 removeTransformer(),已经修改的类也不会自动恢复。Jetty、Undertow、Listener 和 Agent 场景优先重建 JVM,不要直接反射修改私有集合或 Handler 的 next 指针。

在线脱链后立即重新枚举 FilterMap、Servlet Mapping、Pipeline 和 Spring Mapping,并使用无害请求确认可疑路径已不可达。对象可能因线程或缓存暂时留在堆中,不能把“仍有实例”直接等同于“仍能触发”。在线脱链只负责止血,最终仍应重建节点。

四、恢复验收:证明它不会再次出现

恢复节点必须使用新的 PID 和证据目录,不能覆盖受影响节点的原始证据:

1
2
3
4
5
6
7
8
export PID=3512  # 替换为恢复节点的新 Java PID
export VERIFY_CASE="/var/tmp/verify-java-$(hostname)-$(date -u +%Y%m%dT%H%M%SZ)"

umask 077
mkdir -p "$VERIFY_CASE"

JAVA_EXE="$(readlink -f "/proc/$PID/exe")"
JCMD="$(dirname "$JAVA_EXE")/jcmd"

对正常节点和恢复节点发送同一组无害请求:

1
2
3
4
5
6
7
BASE_URL="http://127.0.0.1:8080"

curl -sS \
-D "$VERIFY_CASE/response-headers.txt" \
-o "$VERIFY_CASE/response-body.bin" \
-w 'code=%{http_code}\ntime=%{time_total}\nsize=%{size_download}\n' \
"$BASE_URL/health" > "$VERIFY_CASE/response-meta.txt"

响应包含时间戳或随机值时,不要直接比较整个响应体哈希,应比较状态码、关键响应头和稳定语义字段。同时确认代理、Tomcat AccessLog、应用 Filter 和 Controller 都按预期记录请求。

重新采集恢复节点状态:

1
2
3
"$JCMD" "$PID" VM.command_line > "$VERIFY_CASE/command-line.txt"
"$JCMD" "$PID" VM.classloader_stats > "$VERIFY_CASE/classloaders.txt"
"$JCMD" "$PID" Thread.print -l > "$VERIFY_CASE/threads.txt"

最终检查:

1
2
3
4
5
6
7
8
9
[ ] 初始漏洞或管理面入口已经修复
[ ] 可疑 FilterMap、FilterDef、Servlet Mapping、Wrapper、Valve 已消失
[ ] 正常组件的名称、数量和顺序没有变化
[ ] 启动参数、Agent、共享库和镜像均来自可信基线
[ ] 所有集群节点已经检查
[ ] 账号、Token、证书和 Session 已完成轮换
[ ] CI/CD、制品仓库和部署凭据已经核验
[ ] 恢复节点的请求链、类加载器和网络行为与正常节点一致
[ ] 观察窗口内没有再次出现同类注册、类加载和异常出站

APM、RASP、Byte Buddy、CGLIB、JSP 编译器、OSGi、热部署和合法全局 Filter 都可能改变类或请求链。单个随机类名、空 CodeSource 或一条反射告警不足以定性;结论必须由请求可达、对象持有、来源异常和行为异常共同支撑。

内存对象在重启后会消失,但植入漏洞、恶意制品、启动参数、被盗凭据和受控集群节点不会自动恢复。只有这些复活条件全部被清除,事件才真正结束。

上一篇
下一篇