news 2026/10/17 9:28:20

通用型Java内存马检测实战:Agent+类加载器+线程栈

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
通用型Java内存马检测实战:Agent+类加载器+线程栈

1. 先说清楚:内存马到底“藏”在 JVM 的什么地方

做 Java 安全检测这几年,我最大的体会是:内存马的检测难点从来不在“特征够不够多”,而在“你从哪个视角去看它”。拿传统 Web 后门来说,磁盘上总有个脚本文件,文件特征、落盘时间、目录权限都可以作为切入点。但内存马不同,你翻遍整个文件系统也找不到一个新增的.jsp或.class文件,恶意代码被直接加载进了 JVM 的运行时数据区,以对象、类、字节码的形式存活。这篇实战指南要聊的,就是怎么从“运行时视角”把这些东西找出来,而且尽量不依赖某个特定中间件——也就是标题里说的“通用型”。

1.1 内存马的本质:它是“活的对象”,不是“死的文件”

在 Java 里,一个类从字节数组被ClassLoader加载开始,就变成了永驻堆内存的Class对象。内存马的本质就是:攻击者利用中间件或框架的动态加载机制,把恶意类的字节码从外部传入,或者用defineClass直接定义,让这个类在后端进程里“活”下来。常见的手法大致有四条路径:

  1. Filter 型:通过javax.servlet.Filter注册一个过滤器,拦截所有请求,在doFilter()里执行恶意逻辑。
  2. Servlet 型:动态注册Servlet,映射到一个平常很难被注意到的 URL 路径。
  3. Listener 型:注册ServletContextListener、ServletRequestListener,在容器初始化或每次请求触发时执行代码。
  4. Spring/Struts2 组件型:注册一个Controller或Interceptor,接住特定路由的请求。

这些类型的共同点是:都挂在了某个能接收外部请求的处理链上。所以检测思路的第一性原理很简单——“谁在接收请求?它是不是正常的?”但恰恰是“正常”两个字最难定义。

1.2 为什么传统扫描方案容易失灵

我自己早期也做过文件扫描器,拿哈希、特征字符串去扫磁盘和 Web 目录。这套方案在内存马面前有几个绕不开的死穴:

  • 无文件落盘:字节码直接被传输并加载,磁盘上连临时文件都不一定有。
  • 加密与混淆:Class 字节码可以做整体加密,加载前再解密,字符串特征根本扫不到。
  • 类加载器伪装:恶意类可以被塞到正常的Tomcat公共类加载器里,或者用看起来正常命名的自定义类加载器装载。

甚至有些变种连 Class 文件都不生成,直接用Unsafe.defineAnonymousClass或者MethodHandle动态拼装。这时候你再执着于“文件特征”,就是拿着一把尺子去量空气。

1.3 通用型的真正含义

我理解的“通用型检测”,不是指一个工具能适配所有中间件,而是检测逻辑建立在 JVM 最底层、最通用的机制上:类加载器、Instrumentation、线程栈、Servlet 容器运行时的对象图。不管你跑的是 Tomcat、Spring Boot 内嵌容器还是 Jetty,只要它还是个 JVM 进程,这套底层机制就一定存在。

换句话说,通用型检测是“从地基入手”,新版中间件、新框架出来时,这类检测逻辑往往只需要改少量适配层,不需要推翻重做。下面我分四步展开。

2. 检测方案选型:为什么我要走“Agent + 运行时类加载器 + 线程栈”三条路线

先亮结论:单靠某一种检测手段的覆盖率基本不超过六成。内存马从上传到激活,涉及“类加载”“对象注册”“请求触发”三个阶段,任何一个阶段都可能被拦截,也可能被绕过。所以我的做法是三条线同时扫,最后做关联归一。

2.1 路线一:类加载器与已加载类扫描

这条线盯的是“类”本身。实现上借助 JVM 的Instrumentation接口,先把当前 JVM 里所有ClassLoader枚举出来,再遍历每个ClassLoader下面已加载的Class对象。关键排查点有这么几个:

  • 类加载器来源异常:正常情况下,业务类的ClassLoader不是应用服务器自带的几个,就是框架自定义的。如果出现一个来历不明、名字古怪的ClassLoader,优先级直接拉满。
  • ProtectionDomain 异常:恶意类加载时常没有代码源,ProtectionDomain.getCodeSource()返回null,或者代码源是一个根本不存在的临时目录。
  • 类名不对劲:加载进来的类名与实际的ClassLoader上下文不匹配。比如在一个只应该加载org.apache.catalina相关类的WebappClassLoader里,出现了一个evil/Shell_X_123这种随机类名。

这里有个很关键的实现技巧:不能只遍历ClassLoader能加载到的类,要去翻每个ClassLoader已经加载过的类列表。靠loader.loadClass("xxx")这种主动加载方式只能看到显式加载链路上的类,看不到那些已经躺内存里的东西。正确姿势是用Instrumentation.getAllLoadedClasses()拿全量。

// 关键片段:枚举 JVM 里所有已加载的类,按类加载器分组 Instrumentation inst = getInstrumentation(); Class<?>[] allClasses = inst.getAllLoadedClasses(); Map<ClassLoader, List<Class<?>>> grouped = new HashMap<>(); for (Class<?> cls : allClasses) { ClassLoader loader = cls.getClassLoader(); if (loader == null) { // bootstrap class loader,一般不是排查重点,但可以排除 continue; } grouped.computeIfAbsent(loader, k -> new ArrayList<>()).add(cls); }

拿到分组之后,逐个检查。类加载器本身可以反映很多异常信号:名字里带有随机字符串、close()了但类还在、一个加载器只装了三五个类却全是可疑类名。把这些信号拉成结构化列表,比直接扫特征更通用。

2.2 路线二:Instrumentation Agent 清单扫描

这条线是很多人忽略的。内存马不一定自己注册 Filter/Servlet,也完全可以再挂一个 Java Agent 进去,用ClassFileTransformer在运行时拦截、改写字节码。更有甚者,攻击者直接用Instrumentation.appendToBootstrapClassLoaderSearch往启动类加载器里塞 jar。

怎么发现这类情况?拿到Instrumentation之后,调用getAllTransformer()枚举当前注册了哪些ClassFileTransformer。正常情况下这个列表非常短,基本就是 APM、RASP 这类组件注册的。如果多出一个陌生 Transformer,又查不到对应组件,优先级直接提到最高。

还有一招是自查 jar 包加载路径:System.getProperty("java.class.path")和getBootstrapClassPath()两个列表如果出现了陌生的 jar 路径,也需要追溯来源。我实际排查过一台机器,java.class.path里被人追加了一个/tmp/.cache/agent-0.1.jar,而进程启动参数里根本没有这个路径,这就是典型的 Agent 型内存马。

2.3 路线三:线程栈与活跃线程扫描

类加载器路线能发现“已经加载的类”,但现实中有一种情况:恶意类被加载之后,活动状态不是挂在某一个请求链路里,而是起了一个独立的后台线程在那轮询、等待指令。这类内存马如果只扫类加载器,容易漏。

我的做法是抓线程快照:Thread.getAllStackTraces()之后,逐条过滤满足以下特征的线程:

  • 线程名带随机后缀,或者与业务命名规范明显不符;
  • run()方法栈顶是一个自定义类,且该类不在任何正常 jar 包中;
  • 线程循环里出现了sleep配合wait/notify,典型的“等待指令”模式。

一条线程如果同时满足“自定义类名 + 后台常驻 + 网络 IO 相关调用”,基本就是高危。

不过要小心,光看线程栈一次抓拍不一定准,因为有些恶意线程不是常驻的,而是请求触发时才临时创建、用完再销毁。这时候可以结合“控制台日志里高频且异常的 URL 路径”来做二次筛选。线程池空闲线程、框架自身的ScheduledExecutorService也会误报,需要在白名单里预先过滤掉常见的系统线程前缀,比如http-nio-、ajp-、ForkJoinPool-等。

3. 核心实现:实战一个轻量检测 Agent

理论说完了,下面给一套可直接参考的实现思路。我尽量写关键的骨架代码,避免堆一大堆框架配置。

3.1 动态 Attach 与自保护

检测 Agent 的加载方式分两种:启动时-javaagent预加载和运行时 Attach 到目标进程。实战中我更推荐运行时 Attach,因为内存马往往在线上一台已经跑了一段时间的机器上出现,你不可能为了检测就去重启业务进程。

动态 Attach 的底层依赖jdk.attach模块,核心流程三步:

  1. 调用VirtualMachine.attach(pid)拿到目标 JVM 的引用;
  2. 调用vm.loadAgent(agentJarPath, options)触发 Agent 的agentmain入口;
  3. 在agentmain里拿到Instrumentation实例,开启检测。

这里要给一句提醒:agent jar 的MANIFEST.MF里必须声明Agent-Class和Can-Retransform-Classes: true,否则 attach 进去之后拿不到重转换能力。

自保护的意思是:检测 Agent 本身要防卸载、防篡改。至少做到两点——第一,Agent 内部维护一个心跳线程,发现Instrumentation被清空或Transformer列表里有新成员时立刻告警;第二,不要把检测逻辑全写在一个 jar 里,核心算法可以用独立 class 加载,挂到 bootstrap 搜索路径上,降低被业务层覆盖的风险。

3.2 类加载器扫描的落地代码

下面是枚举类加载器并提取“可疑类”的完整思路:

public class LoaderScanner { private final Instrumentation inst; public LoaderScanner(Instrumentation inst) { this.inst = inst; } public List<SuspiciousClass> scan() { List<SuspiciousClass> result = new ArrayList<>(); Class<?>[] loaded = inst.getAllLoadedClasses(); for (Class<?> cls : loaded) { ClassLoader loader = cls.getClassLoader(); // 1. 排除 JDK 自带的类 if (loader == null) continue; // 2. 排除白名单包前缀 String className = cls.getName(); if (isWhitelisted(className)) continue; // 3. 类加载器标识提取 String loaderDesc = describeLoader(loader); // 4. ProtectionDomain 的 codeSource 判断 CodeSource cs = cls.getProtectionDomain() == null ? null : cls.getProtectionDomain().getCodeSource(); if (cs == null || cs.getLocation() == null) { // 无代码源 + 非 JDK 标准类 = 高危信号 result.add(SuspiciousClass.high(className, loaderDesc, "no-code-source")); continue; } // 5. 类名随机性检测(连续数字/下划线/长度异常) if (hasRandomSuffix(className)) { result.add(SuspiciousClass.medium(className, loaderDesc, cs.getLocation().toString())); } } return result; } }

这里的isWhitelisted做得越全,误报越少。至少要覆盖:java.、javax.、sun.、jdk.、org.apache.catalina.、org.springframework.、com.alibaba.fastjson.这类高频框架包。你要注意的重点是:白名单不是用来“信任”这些包的,而是把排查精力集中到低概率区。真正的高危信号,往往是“不在任何已知包名下、但你业务代码里也没见过”的类。

3.3 Spring 场景的 Controller 检测

Spring Boot 项目里,光靠类加载器扫描还不够。因为很多内存马的玩法是注册一个@Controller或@RequestMapping,处理特定路径。这些类会正常被 Spring 容器管理,类加载器看起来也没毛病。所以得顺着 Spring 自己的ApplicationContext去反查 handler 映射。

实现思路是:先反射拿到当前 WebApplicationContext,然后调用RequestMappingHandlerMapping.getHandlerMethods(),把所有HandlerMethod拿出来。重点看两个维度:

  • Handler 对应的 Bean 类型是不是一个正常的@Controller类;
  • 请求路径是否符合业务命名习惯。

比如requestMappingHandlerMapping里突然冒出一个映射到/favicon.ico或者/static/1.js的 handler,但对应方法体里没有任何业务代码,只是反射调用了一个内部defineClass,这个基本可以定性。

// 伪代码示意:遍历 Spring 容器里的 HandlerMethod,筛选可疑 Controller Object mapping = context.getBean("requestMappingHandlerMapping"); Map<?, ?> handlerMethods = (Map<?, ?>) mapping.getClass() .getMethod("getHandlerMethods").invoke(mapping); for (Object handlerMethod : handlerMethods.values()) { Class<?> beanType = handlerMethod.getClass().getMethod("getBeanType").invoke(handlerMethod); if (!beanType.getName().startsWith("com.yourcompany.")) { // 不在业务包下的 handler,需要重点排查 } }

这段代码只适用于 Spring MVC 体系,如果你手里是 Struts2 或者纯 Servlet 应用,就得换成对应的ActionMapper或ServletRegistration扫描。所以“通用型检测”也不是完全不写适配层,而是要把适配层做得足够薄,核心判断逻辑保持不变。

3.4 输出设计:检测结果要能直接追查

检测 Agent 的输出不要只给一句“发现异常”,要尽量带上下文。我推荐的输出结构是五元组:

字段说明
time发现时间,精确到毫秒
scene检测类型:Loader / Transformer / Thread / Handler
target类名或线程名
detail具体异常信息,如 codeSource 为 null、类加载器异常
level高危 / 中危 / 低危

这样做的好处是,后续接上报系统、打日志或者人工介入时能快速定位,而不是收到一条“发现可疑线程”的空泛告警。

4. 实战中的拦路虎:误报、漏报与反制

纸上谈兵讲检测很简单,真正在生产环境跑起来,问题一个接一个。

4.1 高频误报:框架自身就是“类加载器怪胎”

我遇到过最典型的一个误报场景是热部署框架和字节码增强框架。比如某些运维平台会在启动时动态往 JVM 里塞一个HotSwapWatcher,注册一个ClassFileTransformer来做类热更新。这种组件在getAllTransformer()里看就是“异常新增”,但它确实是正经工具。遇到这种情况,唯一的办法是把这类已知工具的 jar 签名、类名前缀加进白名单。第一次配置可能繁琐,但配上一次后面就稳了。

另一个误报点是Java 动态代理类。JDK 动态代理生成的类名格式是com.sun.proxy.$Proxy123,CGLIB 生成的是xxx$$EnhancerByCGLIB$$abc123。这些类特征本身就带随机味道,很容易被“随机后缀”规则命中。解决办法是把$Proxy、EnhancerByCGLIB、FastClassByCGLIB几个模式单独过滤掉,但同时候要留一条告警日志,因为攻击者完全可能把自己的恶意类伪装成这种命名。

4.2 隐蔽漏报:检测 Agent 被针对性绕过

漏报的情况比误报更让人头疼。我踩过几个坑:

  • Agent 本身被攻击者“取消注册”:通过反射调用Instrumentation.removeTransformer,把检测的 Transformer 摘掉。所以检测 Agent 要定期自检,发现自己不在了立刻告警。
  • 检测线程被杀:攻击者通过线程枚举找到检测心跳线程,直接stop()。常规做法是把检测线程设置成守护线程加Unsafe保护级,但这也不是银弹,关键是心跳断了要能通知外界。
  • 类名混淆:现在很多恶意类加载前直接用随机名字,甚至故意取org.apache.catalina.core.ApplicationFilterConfig这种和正常类重名的包路径,再配合自定义类加载器。这种时候“类名识别”基本失效,只能靠类加载器的来源和codeSource异常来兜底。

对付这类对抗,我的补充做法是做周期性快照对比。第一次扫描建立基线,后续每次扫描把类加载器列表、Transformer 列表、线程列表和基线做 diff。新增的不一定是恶意,但任何新增项都必须有来源解释。如果发现连基线文件都被改了,那就不是内存马的问题,而是主机已经被控了,优先考虑隔离。

4.3 排查建议:检测出来之后别急着删

很多新手拿到告警第一反应是“把这个类 load 出来干掉或者removeTransformer”。这个动作在实战中风险很大:第一,你直接操作 JVM 内存可能引发进程崩溃,影响线上业务;第二,删掉类不等于解决了问题,攻击者可能已经通过这个内存马做过内网横向了。

我的建议是分级处理:

  • 低危:记录日志,持续观察;
  • 中危:隔离对应接口或者将该类retransform成空实现,阻断执行链;
  • 高危:立刻联系业务方,把疑似失陷的进程的流量镜像开启,同时备份内存快照和线程 Dump,再决定是否重启完成“临时止血”。

判断要不要重启,其实是一个业务连续性权衡。如果这台机器可以接受短时重启,那重启是最干净的办法,因为内存马再高级,重启后内存全部清零,但前提是找到中了内存马的入口,否则重启完还会二次中招。

5. 工程化落地:把检测能力嵌进你的安全体系

单人单机跑一个临时 Agent 做排查很容易,难的是把这套能力变成团队可持续运营的安全能力。我分享一下自己踩完坑之后沉淀下来的落地经验。

5.1 对接基础监控,别当孤岛

检测 Agent 产出的告警如果只打到本地文件,等于白做。至少要推到统一日志平台或者企业微信/钉钉告警机器人。我的做法是内置一个轻量 HTTP 上报模块,把五元组结果打成 JSON,POST 到内网的消息网关。这样安全运营同学能实时看到,不用登录每一台机器去翻日志。

5.2 服务器分组与白名单动态化管理

白名单不能写死在代码里。不同业务线的 jar 包、类名模式差异很大,运维平台的注入包、自研框架的动态代理,都会撞上检测规则。所以我把白名单策略做成了外部配置文件,支持热加载。每次误报出现,运营同学可以先加白名单,再定期复盘这条白名单是否合理。宁可白名单多一点,也不要频繁误报把告警渠道搞疲劳。

这里有一个细节:白名单要精确到 jar 包的 SHA-256,而不是只配前缀。单纯配com.github.some-utils.这种前缀很可能被恶意类仿冒。我在实战中被坑过一次,某个恶意类把包名改成了com.github.some-utils.check,前缀完全匹配白名单,直接放过去了。从那之后,所有白名单只要 jar 包有明确来源,一律按哈希加前缀双匹配。

5.3 从检测到处置的闭环设计

内存马检测的终极目标不是“发现”,而是“处置闭环”。我建议把检测结果按以下流程串起来:

  1. 检测 Agent 发现可疑对象并上报;
  2. 安全运营平台自动拉取该进程的线程 Dump 和类加载器快照,留取证据;
  3. 如果是中高危,自动调用中间件接口把可疑 URL 的访问重定向到一个专用蜜罐接口,观察后续行为;
  4. 同步通知业务负责人,由业务侧决定是否重启实例;
  5. 事件结束后,把这次的可疑类特征回填到检测 Agent 的冷启动扫描规则中,实现“打过一次补丁,下一次同类问题检测得更快”。

这一套跑顺之后,检测工作不再是“救火队员”,而是常规巡检的一部分。每周自动扫描一遍全部 Java 应用的类加载器、Transformer 和线程状态,输出一份健康度报告。平时没事件的时候它不吵不闹,真出问题的时候能第一时间站出来。

6. 个人经验笔记:几个代码之外的“软技巧”

最后分享几个技术文档里几乎不会写、但实际排查时很有用的经验。

第一,检测脚本能不动业务进程就不要动。如果你不确定目标 JVM 是否允许 Attach(比如某些容器启用了-XX:+DisableAttachMechanism),优先用 JDK 自带的jcmd先探一下,不行再考虑重启加-javaagent的方式。

第二,Timeline 思维比单点扫描更有效。内存马有没有中,很多时候单看一个时间点看不出名堂。如果线上机器在某个时间点之后,类加载器列表、线程列表、服务响应行为出现异常变化,哪怕没有明确恶意特征,也该跟进排查。我之前处理过一起事件,那个恶意类完全不起眼,但它在进程运行到第 19 天时才首次出现在已加载类列表里,这个时间点刚好和某次外部请求的日志时间对上,才最终确认问题。

第三,和开发同学一起梳理“合法动态加载”清单。很多误报都能通过一份完整的合法动态加载清单解决:哪些框架会做字节码增强、哪些插件会在启动时注入 Agent、哪些业务组件会动态定义类。提前把这份清单拉出来,比事后一条条加白名单高效太多。

以上,就是我对通用型 Java 内存马检测的整体思路和实操记录。检测这块没有一劳永逸的方案,攻击手法在变,JVM 生态也在变,保持对运行时机制本身的敏感,比追逐具体某个特征更值得。希望这篇实战指南能帮你在排查时少走几个弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 9:27:43

4款AI论文写作工具实测:从大纲到降重的全流程拆解

论文写作这件事&#xff0c;我前后折腾了不少年。从最初自己一个字一个字憋&#xff0c;到后来帮学弟学妹改稿&#xff0c;再到现在研究各类AI写作工具&#xff0c;最大的体会是&#xff1a;AI不是替你写论文&#xff0c;而是帮你把写作里最耗时间、最容易踩坑的环节拆解掉。这…

作者头像 李华
网站建设 2026/10/17 9:27:15

Superpowers实战:让AI编程工具按项目级流程干活

最近一年&#xff0c;AI 编程工具基本成了不少人写代码的标配。我身边很多开发朋友日常都在用 Codex、Cursor 这类工具&#xff0c;但用得越深越会碰到一个共同问题&#xff1a;单点能力很强&#xff0c;写个函数、补个测试还行&#xff0c;可一旦要“完整做一件事”——比如把…

作者头像 李华
网站建设 2026/10/17 9:27:14

iroh:基于QUIC与TLS 1.3的P2P加密通信机制解析

1. 从“能连通”到“可信连接”&#xff1a;iroh 到底在解决什么问题做 P2P 通信的人应该都有一个感受&#xff1a;让两个节点连起来并不难&#xff0c;难的是让它们“安全地”连起来。我在早期做点对点传输时踩过不少坑&#xff0c;最典型的问题是——你用 UDP 打通了 NAT&…

作者头像 李华
网站建设 2026/10/17 9:26:25

macOS上用Docker部署nginx+php:从零搭建容器化开发环境

你在macOS上装过nginx和php吗&#xff1f;如果装过&#xff0c;大概率体会过那种“装起来半小时、调起来一晚上”的滋味。今天这篇我就带你完整走一遍macOS上安装Docker&#xff0c;然后在Docker里部署一套nginxphp环境的过程&#xff0c;整个过程不追求什么高大上的编排技巧&a…

作者头像 李华
网站建设 2026/10/17 9:25:43

RAVEN:面向事件密度与语义关联的自适应窗口引擎

1. 为什么“固定窗口”成了时序数据处理的隐形枷锁我第一次在金融风控系统里看到“每15分钟跑一次特征计算”的调度脚本时&#xff0c;没觉得有问题。直到某天凌晨三点&#xff0c;一笔异常交易在14分58秒发生&#xff0c;而模型要等到15分00秒才触发窗口聚合——那两秒&#x…

作者头像 李华
网站建设 2026/10/17 9:25:12

智能体能力评估实战:从Harness Engineering到Agent评测体系搭建

做Agent应用开发和做传统软件有一个特别不一样的地方&#xff1a;传统软件你测的是“逻辑对不对”&#xff0c;而Agent你测的是“能力的边界在哪里”。我在不少项目里见过同一个Agent&#xff0c;换一种Prompt表述效果就天差地别&#xff0c;换一套运行环境直接罢工&#xff0c…

作者头像 李华