☰
Java内存马原理与排查:不落盘的幽灵如何发现与清理
2026/10/20 10:06:57 网站建设 项目流程

做安全的人对两个字特别敏感:未知。系统明明没有多出任何文件,磁盘扫描干净利落,WAF日志也没有告警,偏偏业务接口这几天一直出现莫名其妙的调用,CPU时不时飙高。如果你也遇到这种情况,先别急着怀疑监控误报,很可能是JVM进程里住进了一个不速之客——Java内存马。

内存马这个词在Java安全圈里已经不算新鲜事,但它至今仍是让很多开发者和运维头疼的“幽灵”。它不写文件、不进磁盘、不触发大多数基于文件特征的查杀规则,就静静躺在内存里,等某个特定请求触发它的逻辑。本文不讨论任何攻击工具的使用方法,而是从原理、注入路径、查杀手段和加固思路四个角度把它拆清楚,重点是怎么发现它、定位它、清理它。如果你负责Java应用的开发、运维或者安全审计,这篇内容可以给你一个相对完整的排查思路。

1. 内存马:让传统查杀扫不到的那道影子

1.1 什么是Java内存马:它与JSP后门的关键差异

很多老安全工程师都熟悉传统WebShell的排查方式:扫目录、比对文件哈希、看上传时间、查危险函数特征。比如曾经的JSP后门,不论是大马还是小马,总归要落一个JSP文件在web目录下,文件扫描引擎只要特征库够全,基本能把它揪出来。

内存马完全走了另一条路。它不会在web目录下留下任何JSP、class、jar之类的实体文件,而是借助Java Web容器(比如Tomcat、Jetty、Spring Boot内嵌容器)的运行时特性,把恶意逻辑直接注册到内存中的组件列表里。你可以把它理解为:酒店前台登记簿上没有新增任何客人,但是某个房间的入住状态已经在后台系统里被悄悄改掉了。

这样说可能有点抽象,我们换个生活类比。传统WebShell就像你在小区公告栏贴了一张小广告,保安巡逻一圈就能发现并撕掉;内存马则像是混进业主群的某个人,表面上看群里成员列表没变化,但他能接收到所有通知,还能伺机在群里发消息。群管理不仔细核对成员注册信息,根本不会意识到多了一个“隐形业主”。

内存马的典型特点是:执行代码不落地,生命周期跟随JVM进程,进程一重启往往就消失(但攻击者可能借助持久化手段重新注入)。这也是它被称为“幽灵”的原因——平时感觉不到,一旦活跃起来,影响面可能远超预期。

1.2 JVM类加载机制:内存马赖以生存的土壤

内存马能在JVM里“生根”,核心依赖的是Java的类加载机制。JVM运行Java程序时,并不是一次性把所有类都加载进来,而是按需加载:程序用到哪个类,类加载器(ClassLoader)就去指定位置读取并初始化它。这种机制的初衷是为了节省内存、提升启动速度,但同时也意味着:只要某个代码片段能拿到一个 ClassLoader 实例,并且往其中动态注册了自定义类,那么这段代码就能在运行时被加载、实例化并执行。

这种能力本身是Java生态的常见操作,很多框架的热部署、插件化设计都依赖它。Spring Boot的DevTools、OSGi插件体系、Java Agent字节码增强,本质上都在做类似的事。内存马的作者只是把这种“合法能力”用在了非法场景上:先通过某个漏洞拿到JVM内执行代码的权限,然后动态创建一个类加载器去加载一段恶意字节码,再把这段字节码伪装成容器认可的组件(Filter、Servlet、Listener等),注册进容器运行时。从此刻开始,容器处理请求时就会经过这个“新组件”,恶意逻辑随之获得执行机会。

这里有个容易混淆的概念:内存马并不一定需要单独的自定义ClassLoader。在某些框架场景下,攻击者可以直接利用现有的上下文ClassLoader加载恶意类,只要类名和逻辑足够隐蔽,整个过程在内存里就像一次普通的动态加载,非常难被静态扫描发现。

1.3 为什么WAF、EDR与文件扫描工具拿它没办法

先说明一点:WAF、EDR、文件扫描工具各有各的用处,并不是说它们无效,而是它们的检测模型对内存马天然不友好。

WAF主要拦截在网络层和应用层之间的恶意请求,它能识别的是请求特征,比如URL路径、参数里的危险函数、SQL注入特征等。内存马的触发URL往往是一个看起来完全正常的路径,比如/api/query、/index,没有明显的攻击Payload在网络上传输,WAF看到的就是平平无奇的HTTP请求。

文件扫描工具依赖磁盘上的文件特征,内存马压根不写文件,扫描引擎连目标都找不到。EDR类工具虽然会监控进程行为,但对Java应用的检测通常要深入JVM内部,看类加载情况、看线程栈、看堆内存对象,这些信息如果没做专门的适配,EDR很难判断哪个类是恶意的。太容易误报也导致很多安全团队不敢直接按“可疑类加载”来阻断。

所以说,内存马之所以让人头疼,本质上是因为它把战场从“文件系统”转移到了“运行时内存”,而大多数传统安全产品还没完全适应这个变化。

2. 内存马的几种“寄生”姿势与注入原理

2.1 Filter型:顺着请求过滤器链做手脚

在Java Web开发中,Filter(过滤器)是一个非常常见的组件。它可以在请求到达Servlet之前、响应返回给客户端之前做一些拦截和处理,比如登录校验、日志记录、编码转换。容器在启动时会把所有Filter按顺序组成一条链,请求进来时按链逐个通过。

Filter型内存马的做法,就是在运行时动态添加一个新的Filter到这条链中。这个Filter本身看起来没什么特殊,但它会在doFilter方法里判断请求参数:如果参数符合某个约定标记,就执行一段特殊逻辑;否则就正常放行。这样一来,攻击者可以带着特定标记访问任意URL,每次都能触发恶意逻辑,而普通用户访问时一切正常,谁也不会注意到哪里不对。

从检测角度看,Filter型内存马的隐蔽点在于:它注册到了容器标准组件里,很多监控脚本如果只遍历已有Filter的配置,不一定能发现运行时新增的Filter。而且Filter的顺序可能被插到非常靠前的位置,优先于业务权限校验逻辑执行,从而绕过认证。

2.2 Servlet与Listener型:容器组件的另类寄生

Servlet型内存马和Filter型类似,但寄生的组件变成了Servlet。Servlet通常对应一个URL路径,你注册了一个/admin的Servlet,它就能接收/admin下的所有请求。内存马若以Servlet方式注入,等于在容器里新增了一个“隐蔽入口”,访问特定路径就触发恶意逻辑。这种方式的缺点是不够隐蔽:路径是网络请求直接可见的,如果安全团队做了URL盘点,很快能发现异常路由。

Listener型则藏得更深。Listener用来监听Web应用中的事件,比如应用启动、会话创建、请求初始化等。内存马可以注册一个自定义Listener,在某个事件触发时执行逻辑。由于Listener本身不直接关联某个URL,它更像一个“背景任务”,等到特定条件满足才干活,所以静态排查时几乎扫不到,动态观测时也容易与正常的业务监听器混淆。

我个人的经验是:实际遇到的内存马,Filter型占比最高,其次是Spring框架型,纯Listener型的场景相对少一些,但一旦遇到,排查难度会翻倍。

2.3 框架型:Spring/Controller层的暗渡陈仓

如果你用的是Spring Boot或Spring MVC,攻击者还有一条更贴合框架特性的注入路:动态注册一个Controller或者HandlerMapping。Spring允许开发者在运行时注册新的Controller,可以通过实现特定接口把某个类的实例动态挂到Spring容器中。内存马如果走这条路,就会伪装成一个正常的业务Controller,拥有自己的URL路由,逻辑里却藏着暗门。

这种方式的可怕之处在于,它完全融入框架生态。安全人员排查时看到的可能是一个Controller Bean,类名看起来像模块代码,还会被Spring的正常机制管理,很难一眼看出问题。而且它通常不需要修改任何核心配置文件,也没有独立的文件可查。

另有一种更“轻”的思路是直接动态注册HandlerMapping,把自己的方法绑定到一个路径上。这种方式甚至不需要完整生成一个Controller,只要返回一个可调用的Handler即可,排查难度同样很高。

2.4 注入的前置条件:不是想进就能进

说到注入口,很多人会误以为内存马是“凭空出现”的。实际上,要注入内存马,攻击者必须先获得JVM内的代码执行权限。常见的入口包括:存在远程命令执行(RCE)漏洞的接口、反序列化漏洞利用链、表达式注入(如某些模板引擎直接把用户输入当作表达式解析)、以及不安全的动态类加载接口。也就是说,内存马通常不是第一步,而是“漏洞利用成功之后”的二次动作。

这也是防御方可以抓住的关键节点。如果你能及时发现最初的命令执行或反序列化利用尝试,就能在内存马注入之前阻断整条链路。很多安全团队把内存马查杀做成“事后手术”,其实不如在注入入口处多做文章——先把漏洞堵住,幽灵根本没有机会进来。

3. 从一次“幽灵事件”看注入链路与排查入口

3.1 现场还原:一个看似正常的请求处理异常

前阵子我处理过一起事件,复盘过程很典型。某内部管理平台平时流量不大,某天监控显示某个接口响应时间从平均200毫秒飙升到3秒以上,而且伴随周期性的CPU尖峰。开发同事最初怀疑是数据库慢查询,排查了一圈SQL没发现问题;重启应用后指标暂时恢复,但几小时后再次恶化。

后来查容器访问日志,发现一个奇怪的规律:每隔一段时间,就有一个来自内网IP的请求访问/health路径,这个路径本应返回固定字符串,但请求参数里多了一个不相关的字段。起初以为是健康检查探针,但仔细看它访问频率和CPU尖峰时间高度吻合。最终通过线程转储(thread dump)定位到一条可疑的自定义线程,顺着线程栈摸到了动态注册的Filter对象,才确认是内存马事件。

3.2 链路拆解:入口、载体与执行时机

这起事件里的注入链路其实不复杂:攻击者先利用某旧接口的反序列化漏洞获得了Java进程内执行命令的能力,然后通过动态类加载在内存里注册了一个Filter。这个Filter被插在过滤器链靠前的位置,正常请求全部放行,只有携带特定参数值的请求才会进入恶意分支,执行下载器逻辑或者反弹连接操作。

处理请求时,Filter会先判断参数是否存在约定值,没有就立刻放行;有就进入恶意逻辑,通过网络加载一段额外的字节码或直接执行系统命令。因为恶意逻辑只在极少数请求中出现,常规业务日志里根本看不到异常,只有流量分析和线程栈这类“更深层”的观测手段才能捕捉到蛛丝马迹。

3.3 蓝队视角:发现异常后应该盯住哪些信号

结合这次复盘,我整理了几个对防御方价值比较高的信号:

  • 线程名称与线程栈异常:JVM线程转储里出现非业务代码创建的线程,名称像乱码或与业务命名规范不符,且长期存活。
  • 请求参数与行为不匹配:比如健康检查接口带了大段参数,静态资源请求带了执行命令的特征值,这类“多出来的东西”值得深挖。
  • 类加载器实例数量异常:JVM中ClassLoader对象数量激增或出现无法归属到正常模块的类加载器,需要警惕动态加载。
  • 内存堆里出现可疑的字节数组或类定义:可以通过堆转储分析发现动态生成的Class对象,尤其是类名带有Base64解码特征的字符串。

这些信号单独看都可能被误判为正常,但组合起来出现时,基本可以判定存在内存马或类似的可疑动态加载行为。

4. 查杀与清理:从手工定位到自动化防御

4.1 先用JDK自带工具做第一轮排查

当你怀疑一台Java应用服务器被注入了内存马,第一步不一定要立刻上商业产品,先用JDK自带工具做初步判断往往更直接。

常用的手段是jmap和jstack。jstack可以输出线程栈,帮你快速识别可疑线程和它们正在执行的类;jmap的-dump参数能把堆内存导出来,再用MAT或者jhat等工具分析堆对象,搜索可疑的类名、类加载器和字节数组。如果是Filter型内存马,堆里大概率会有对应Filter类的实例,通过查看它的引用关系,可以顺藤摸瓜找到注入点。

还可以用jcmd查看JVM已加载的类列表。命令类似jcmd <pid> GC.class_histogram或jcmd <pid> VM.system_properties,配合具体环境还可以执行ClassLoader.print_class_loaders之类的诊断命令。这个过程比较费时,但拿到第一手数据后,后续定位就踏实很多。

需要注意的是:导出堆转储和应用重启是互斥的。很多团队一发现问题就本能重启,结果内存数据全部消失,成了“死无对证”。正确流程是先保存现场(线程栈、堆、网络连接快照),再考虑隔离或重启。

4.2 Java Agent与Instrumentation:把内存里的“幽灵”揪出来

手动分析堆转储适合小规模精准排查,但要在大规模集群里自动化识别内存马,通常要靠Java Agent机制配合Instrumentation API。

Instrumentation是JDK提供给开发者的一种能力:它可以在JVM运行时修改已加载类的字节码。利用这个机制,安全产品可以注入一段自己的监听逻辑,在关键类(比如Filter、Servlet、Spring的DispatcherServlet)的入口处埋点。当内存马尝试注册恶意组件时,监听逻辑会捕获到这次注册行为,检查类来源、ClassLoader归属、方法和字节码特征,凡是无法匹配到已知业务模块的动态类,就拉响告警。

这套方案也有副作用。字节码增强本身对性能有影响,埋点过多会让核心请求链路变慢;而且某些框架内部本身就有大量动态代理类,如果特征库做得不够精细,误报率会高到运维不再看告警。所以做Agent式检测一定要先熟悉自家应用的类加载基线,把正常动态加载的类列入白名单。

4.3 从日志与交互特征判断内存马的交互方式

工具检测之外,行为分析也很重要。内存马虽然不落盘,但它总要“干活”,干活就会产生网络交互、日志记录、外部命令执行等痕迹。

检查应用运行时有没有发生“本不该发生的网络连接”,尤其是JVM进程主动外连一些非业务域名或IP。可以配合网络层监控,把Java进程的对外连接全部记录下来,再和业务白名单比对。另一个重点是Web日志中的畸形请求:路径正常但参数异常、没有业务含义却频繁访问、特定URL的User-Agent或Header恰好带有一串可疑标记。这些特征单独看不致命,组合起来足够让安全工程师警觉。

我还建议日志采集时保留请求头中的自定义Header字段。内存马常用Header或参数传递触发口令,如果你把Header丢弃了,等于主动删掉了最直接的排查线索。

4.4 清理后的加固:把“寄生土壤”翻一遍

确认内存马并清理掉对应Filter/Servlet/Controller实例后,最怕的一件事就是“只摘果子不挖根”。内存马能被注入,说明上游存在可利用的漏洞。如果漏洞不补,今晚清理完,明天又会有一个新的“幽灵”住进来。

至少要做以下几件事:

  • 对被利用的接口进行代码审计,修复反序列化、命令执行、表达式注入等漏洞。
  • 在应用前面加一层专门检测动态注册行为的防护,比如运行时不开放动态编译、严格限制自定义ClassLoader的使用。
  • 检查应用账号与中间件账号权限,收回不必要的远程管理和工具链权限。
  • 对JVM进程做基线管理:记录启动参数、常用类加载器、已注册组件列表,便于后续比对异常增量。

这里要特别提醒:不少组件在正常运维过程中也会动态注册新类或新路由,如果发现异常就全盘封杀动态加载,应用可能直接瘫痪。正确的做法是先建立基线和白名单,再对白名单之外的增量做告警。

5. 我踩过的坑与给你的一线建议

5.1 排查过程中的三个重要误判

第一是“重启后恢复正常就没事了”。内存马有可能在应用重启后消失,但攻击者可能已经利用它做了持久化操作,比如写入计划任务、修改配置中心、下发恶意Agent到你管理的其他节点。只看单台机器的“重启恢复”,会漏掉更大的风险面。

第二是“CPU飙高一定和内存马有关”。JVM的CPU异常有很多原因,频繁GC、线程死循环、业务慢SQL都可能。遇到CPU高就先扣帽子到内存马,会让排查方向跑偏。理性的做法是先把线程栈拿到手,看热点线程到底在跑什么,再决定后续动作。

第三是“类名可疑就能删除”。实际处理时,你会碰到很多类名也乱、包名也怪但其实是正常框架代理类的情况。盲目删类或停进程,可能直接导致核心业务中断。我的习惯是:先通过ClassLoader引用链判断这个类是谁加载的、被谁持有,确认没有业务引用,再考虑回滚或隔离。

5.2 最适合当前情况的防御组合

以我目前的经验来看,单点防御很难应对内存马,比较实用的组合是:

  • 入口侧做漏洞治理,优先修复反序列化和表达式注入类高危漏洞,减少内存马的“接入机会”。
  • 运行时用Java Agent类方案做动态注册监控,对新增Filter/Servlet/Controller类保持敏感。
  • 观测侧完善线程栈和堆转储的定期采集,保留历史快照,为事后对比提供依据。
  • 行为侧梳理JVM对外网络连接白名单,让任何异常外连都变得扎眼。

这四层不一定要一次全上,但至少要先把“漏洞治理”和“行为侧监控”做扎实。很多团队在Agent方案上卡了很久,反而忽略了入口侧补漏才是性价比最高的一步。

5.3 最后一公里的落地习惯

最后分享一个落地习惯:把“进程内组件清单”纳入日常巡检。你可以写一个简单的巡检脚本,定期连接JVM的JMX端口,导出一份当前已注册的Filter、Servlet、Listener和Spring Controller清单,和上一次的基线做Diff。这个动作不难,但在很多真实事件里,就是这份Diff让“幽灵”无处遁形。

我自己在实践中的体会是,对付Java内存马没有一劳永逸的银弹,更多拼的是“平时有没有积累基线”和“出事后能不能冷静地保留现场”。工具只是放大器,真正起作用的还是排查者对JVM运行时机制的理解程度。希望这篇内容能帮你把内存马的原理和排查脉络理清楚,以后真遇到那道影子时,少走我走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询