Spring MVC拦截器内存马:动态注册原理与检测防御实战

发布时间:2026/10/24 0:30:23
Spring MVC拦截器内存马:动态注册原理与检测防御实战

内存马在Java安全领域一直是绕不开的话题,而Spring框架下的Interceptor拦截器内存马,算是其中很隐蔽的一种形态。它不写文件、不动Filter、甚至不碰Servlet容器,而是把逻辑挂在Spring MVC处理请求的拦截器链上。这篇文章我会从Spring MVC的请求处理链路说起,拆解动态注册Interceptor的核心机制,再用一次授权测试里的排查经历讲清楚检测思路,最后给出一份相对完整的自查清单。内容适合正在研究Java安全的应用安全同学,也适合想深入了解Spring内部机制的后端开发者。

先说清楚一个前提:所有涉及动态注入的验证,我都只在授权测试环境里做过,没有经过授权的系统碰都不要碰。安全研究的价值在于提前发现风险、帮助防守,而不是绕过规则。

1. 为什么Interceptor能成为内存马的载体:请求在Spring MVC里的必经之路

1.1 Filter、Servlet和Interceptor三者的位置差异

很多人一聊到Java内存马,第一反应是Filter。因为Tomcat的容器层Filter链足够底层,一旦注入成功,几乎对所有请求生效。但Filter的注册位置在Servlet容器,和Spring容器是两层。而Interceptor挂在Spring MVC内部,离业务代码更近,修改它不需要惊动容器,只需要在Spring上下文里“动手脚”。

一个典型请求在Spring Boot应用中的流转大概是这样的:

  1. 请求先到达Servlet容器,经过一堆Filter(如Spring Security的过滤器链)。
  2. 进入DispatcherServlet,这是Spring MVC的前端控制器。
  3. DispatcherServlet通过HandlerMapping找到处理请求的Handler,也就是Controller方法。
  4. 在真正执行Controller方法之前,Spring会依次调用HandlerInterceptor的preHandle方法。
  5. Controller方法执行完后,再调用postHandle和afterCompletion。

也就是说,只要HandlerMapping里的Interceptor列表里有东西,每个匹配的请求在执行前都会先撞上它。这个位置天然适合做“检查和改写”,也正因如此,如果被塞进恶意逻辑,它就能对请求和响应做不少事情。

1.2 与Filter型内存马相比,Interceptor型隐蔽在哪

Filter型内存马需要拿到容器上下文或Servlet上下文,而Spring Boot应用里最常见的场景是“已经获得了Spring上下文内的代码执行能力”。这种情况下,往HandlerMapping里添加一个自定义HandlerInterceptor,要比往Tomcat里塞一个Filter更“顺手”,因为SpringApplicationContext本身就是攻击者已经掌握的对象池。

另外,常规的Webshell扫描工具会把磁盘文件、字节码特征作为重点,Interceptor内存马没有独立文件,它只是运行期一个对象。你翻web目录、翻上传目录、翻日志,都不一定有线索。业务代码里大量自定义拦截器也很常见,比如登录校验、权限校验、日志记录,所以想在运行期发现一个“多出来的”拦截器,光靠眼睛看是看不出来的。

这里有一个容易忽略的细节:Spring Boot在自动配置阶段会创建RequestMappingHandlerMapping这个Bean,它处理绝大多数@Controller请求。而AbstractHandlerMapping基类里有一个adaptedInterceptors字段,类型是List<Object>。这个字段就是所有已适配拦截器的容器。所谓动态注册Interceptor内存马,本质上就是往这个List里追加一个对象。

理解了这个位置,后面检测和加固的方向也就清晰了。

2. 动态注册Interceptor的底层逻辑与关键前提

2.1 拦截器在Spring容器里到底存在哪里

AbstractHandlerMapping这个抽象类是所有HandlerMapping的父类。它内部维护了一个adaptedInterceptors列表,在初始化时通过initInterceptors()方法把配置好的拦截器加进去。如果你在WebMvcConfigurer.addInterceptors里注册过自定义拦截器,这些拦截器最终也会落到这个列表里。

正常情况下,这个列表里的元素只有两种来源:

  • 实现了HandlerInterceptor接口的普通对象。
  • MappedInterceptor包装对象,它内部持有HandlerInterceptor和路径匹配规则。

关键点在于:adaptedInterceptors是实例字段,不是常量,也不是不可变集合。只要代码能拿到RequestMappingHandlerMapping这个类的实例,就可以通过反射把自定义对象追加到该List里。追加之后,Spring处理请求时遍历拦截器链,自然就会调用到新加的这个拦截器。

打个比方:拦截器列表就像一个派件仓库里的“快递员花名册”,正常情况是公司行政添加的。如果你的系统被入侵,攻击者能拿到仓库钥匙,往花名册里加一个“特殊快递员”,那么后续每一批派件都会先经过他。

2.2 运行时拿到HandlerMapping实例的几条路径

在已经获得代码执行能力的前提下,拿到RequestMappingHandlerMapping实例并不是难事。最常见的方式包括:

  1. 从ApplicationContext中按类型获取Bean:applicationContext.getBean(RequestMappingHandlerMapping.class)。
  2. 通过@Autowired注入,当然动态注入场景下没法用注解,一般用前一种。
  3. 如果存在多个HandlerMapping(比如BeanNameUrlHandlerMapping),需要自己判断当前请求实际走的是哪一个。

这个“取Bean”的动作本身很普通,业务代码里到处都在用。所以防守方不能只防这个动作,而要从更底层去防“未经允许的自定义类被加载并注册”。

顺带提一下Spring版本差异。Spring 5.3之后,默认的路径匹配策略引入了PathPatternParser,MappedInterceptor里保存的不再是简单的AntPathMatcher字符串规则,而是PathPattern。反射往adaptedInterceptors里追加对象时,如果追加的是普通HandlerInterceptor,它会被当作全局拦截器处理,作用范围是所有请求,这比自定义路径规则的MappedInterceptor影响范围更大。

2.3 动态注册能成功的前提条件与边界

需要把话说清楚:动态注册Interceptor这件事本身不是漏洞,而是Spring对外扩展点的一部分。不是所有系统都能随随便便成功,至少需要满足三个条件:

  • 攻击者已经有代码执行能力,比如通过反序列化、SQL注入写文件配合、表达式注入等拿到任意代码执行。
  • 当前请求线程能访问到Spring容器,也就是能拿到ApplicationContext。
  • JVM没有限制反射调用的敏感访问,比如JDK 17强模块系统下,对java.base模块的反射限制会更严格,但对Spring自己的类影响有限。

所以本质上,内存马是“二次利用”的产物。它不是独立的攻击入口,而是攻击者拿到一次执行机会之后,为了保持控制权、逃避检测而做的持久化操作。反过来说,防御方如果把精力全部放在“清内存马”上,而没堵住最开始的代码执行漏洞,那清理完很快又会被写入新的。

3. 一次授权测试里的“排查链路”:从异常日志到藏在链尾的拦截器

3.1 现场现象与初步判断

某一次授权测试的收尾阶段,我们在一套Spring Boot应用里发现一个很有意思的现象:一个正常业务接口返回正常,但响应里多了一个自定义响应头,并且访问一个根本不存在的URL时,也会返回200状态码,响应体还是空。

正常情况下,不存在的URL应该由Spring MVC抛出404。直接返回200说明有东西在Controller还没执行时就把请求短路了。这个行为与HandlerInterceptor.preHandle返回false的表现一致——请求被拦截,不再继续执行后续链路。

初步判断有两个可能:一是系统里本来就配置了某个全局拦截器做了“吞请求”逻辑;二是存在额外的拦截器注入。为了区分这两种可能,我们需要直接去看adaptedInterceptors里到底有哪些对象。

3.2 用堆转储和JVM诊断命令定位异常对象

排查的时候不能只凭日志猜,最直接的方法是看运行期对象。我们当时的做法是:

  1. 先通过JDK自带的堆转储工具,导出一份堆快照。
  2. 再用内存分析工具打开快照,搜索AbstractHandlerMapping的子类实例。
  3. 找到RequestMappingHandlerMapping实例后,展开它的adaptedInterceptors字段,查看列表长度和每个元素的类名。

正常应用里,拦截器数量一般是几个到十几个,类名大多来自项目包名或Spring框架包名。当时列表里出现了一个类名非常“干净”的拦截器,包名路径很短,看起来不像普通业务类。

这里给个自查时可以直接用的思路,用反射把当前所有HandlerMapping里的拦截器列出来:

List<HandlerMapping> mappings = applicationContext.getBeansOfType(HandlerMapping.class) .values() .stream().toList(); for (HandlerMapping mapping : mappings) { try { Field field = AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors"); field.setAccessible(true); List<?> interceptors = (List<?>) field.get(mapping); System.out.println("Mapping类: " + mapping.getClass().getName()); for (Object interceptor : interceptors) { System.out.println(" 拦截器: " + interceptor.getClass().getName() + " 加载器: " + interceptor.getClass().getClassLoader()); } } catch (Exception e) { // 反射访问在新版本JDK下可能需要额外处理 } }

这个代码本身没有任何攻击性,它的作用是让你的应用自己“照镜子”。运行时如果发现列表里出现了不属于你的类加载器的拦截器,就需要马上警惕。

3.3 找到挂载点后的验证方式与结论

定位到可疑拦截器后,我们先用Class.getProtectionDomain().getCodeSource()看了它的类加载来源,发现没有一个对应的代码源,说明它的类是动态生成的。再结合响应头特征,基本确认这就是一个动态注册的Interceptor内存马。

随后我们把应用重启,再访问同样的URL,异常响应头消失。这也印证了“内存马不落盘、重启即失效”的特点。但要注意,如果攻击者同时有外部的持久化通道(比如计划任务、自启动脚本),重启后内存马会再次被注入,所以排查不能止步于“重启一下”。

这次排查给我最大的感受是:动态注册Interceptor的迷惑性并不在于技术多高级,而在于它藏在一堆正常拦截器中间,如果不主动查看运行期对象列表,几乎不会有感知。

4. 从检测角度拆解:如何训练眼睛识别异常Interceptor

4.1 基于对象快照的基线比对

对于生产环境,我建议在每次发版后,第一时间用脚本记录当前应用里的拦截器基线,包括拦截器数量、类名、ClassLoader、代码来源。把这些信息存成一份快照文件,之后定期巡检时做对比。

一张简单的对比表可以帮助判断:

维度正常特征可疑特征
类名项目包名规范,业务含义明确包名短、无业务含义,或包含Base64解码痕迹
类加载器应用系统加载器或框架加载器自定义ClassLoader,或距离默认加载器很远
代码来源有明确的jar包路径或classes目录为null,或指向临时目录
实例数量稳定,每次启动一致与部署记录不一致,启动后动态增加
接口行为与配置代码逻辑对应出现未配置的处理逻辑,或响应头/响应体异常

这个表不是金标准,但能帮你快速过滤掉绝大多数正常情况。如果一条记录对应了多个可疑特征,那就要深入看。

4.2 从请求行为特征找线索

不是所有环境都允许你jmap、jstack。在这种场景下,行为侧的异常特征可以作为辅助判断。

举几个实际出现过的信号:

  • 访问一个不存在的URL,状态码是200,而不是404。
  • 请求完成后,响应头里多出了莫名其妙的字段。
  • 某些请求的响应耗时突然出现固定延迟,像是被额外处理逻辑拖住。
  • 请求参数里的特殊值会触发不同响应,比如传了某个约定好的参数后,返回内容明显改变。
  • 正常过滤器、拦截器原本打印的日志顺序发生变化,或者出现了重复输出。

这些信号单独看都不足以定论,但如果同时出现多个,就该去检查拦截器列表了。拦截器内存马本来就会改变请求的处理时机,所以从“请求行为变异”反向追踪,是很有效的分析思路。

4.3 借助Java Agent做运行期扫描

对于有条件的环境,可以挂一个自定义Java Agent,在类加载和对象实例化阶段做钩子。这不是普通业务能轻易做的事,但确实有团队会针对Spring MVC的AbstractHandlerMapping做专门的防护。

简单说思路:Agent在premain阶段,用ClassFileTransformer或者Instrumentation API去监听org.springframework.web.servlet.handler.AbstractHandlerMapping类。当有代码调用addInterceptor或反射修改adaptedInterceptors时,记录调用栈,并判断调用来源是否在白名单内。一旦发现非白名单类在操作拦截器列表,直接抛出异常或记录告警。

这种机制比较重,更适合有专门安全团队的公司。对于大多数项目,定期快照比对已经能覆盖绝大多数风险。

5. 防御与自查清单:把Interceptor内存马当作扩展点风险来治理

5.1 注册面收口:别让WebMvcConfigurer成为攻击者的跳板

业务系统里如果有自定义的WebMvcConfigurer,比如统一登录拦截、接口鉴权拦截,一定要保持精简。不要在拦截器里写危险逻辑,也不要暴露一个能从HTTP入口触达任意Spring Bean的方法。比如某个接口接收一个类名,然后用反射去调用该类的方法,这种设计一旦存在,等于是把Spring容器整个交了出去。

如果条件允许,可以自己封装一个WebMvcConfigurer,重写addInterceptors方法,限制只能添加预先定义好的拦截器白名单:

@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor); registry.addInterceptor(auditInterceptor); // 不允许其他外部类再调用registry.addInterceptor }

但说实话,这套防御只能挡住“配置阶段”的注入。攻击者如果在运行期直接反射改adaptedInterceptors字段,这个重写是拦不住的。所以它只是防御体系里的一层,不是全部。

5.2 运行期守护:定期校验已注册拦截器的Class来源

我给团队的例行检查脚本里,会做下面几件事:

  • 启动时自动生成一个拦截器指纹文件,包含所有Interceptor类名和代码来源哈希。
  • 每天凌晨通过JMX或一个受保护的管理接口,重新读取当前拦截器列表,与指纹文件比对。
  • 比对结果写入独立日志,如果发现新增、删除或类名变更,立即触发告警。

具体到实现,就是把之前那一段反射读取adaptedInterceptors的方法,包成一个定时任务。管理接口本身要严格限制IP、鉴权和调用频率,防止被攻击者利用来探测当前状态。

这里要特别注意:如果应用本身存在RCE漏洞,攻击者同样可以伪造这个名单。所以运行期守护更多是“发现”作用,不能完全依赖它。

5.3 从源头防RCE比清内存马更重要

这句话我每次都会强调,内存马只是“症状”,不是“病根”。一个攻击者能把Interceptor塞进你的内存,说明他已经在你系统里执行过代码了。清掉一个拦截器,如果漏洞还在,他换个姿势又能进来。

所以防御的重心应该落在:

  • 依赖库漏洞扫描,尤其是反序列化链涉及的高危组件。
  • 对外接口严格做参数校验,防止表达式注入、模板注入。
  • 上线前的Web应用渗透测试,覆盖越权、任意文件读写、命令注入等场景。
  • 最小权限原则,应用运行账号不要给到宿主机的高危权限。

这些工作看起来和“内存马”离得很远,但实际上它们是决定你能不能睡好觉的关键。

5.4 把异常迹象纳入监控告警体系

拦截器列表变化只是众多攻击迹象中的一种,建议把下面几个指标都接进监控体系:

  • Spring上下文里自定义Bean数量的变化。
  • HandlerMapping中Interceptor列表长度和对象哈希。
  • 非业务代码主动调用反射的次数和调用栈。
  • 动态生成Class的数量。

不需要每个都做成实时拦截,可以先做基础数据采集。这些指标单独看都正常,但组合起来能提供很强的关联分析能力。比如某个时间段,反射调用突增、动态类生成突增、随后拦截器列表变化,那基本可以判定有一个完整的注入过程发生了。

写在最后的经验

拦截器内存马真正让人头疼的地方,其实不是技术有多高深,而是它和正常业务代码完全共用Spring MVC的同一套扩展机制。你很难用一个简单的特征去一刀切,误判率太高。我自己的习惯是把Spring的adaptedInterceptors当成“敏感资产”来管理,启动时记录指纹,上线后定期看差异。哪怕没有任何异常,每个月花两分钟查一次,成本很低,却能避免很多被动局面。

另外一个容易被忽略的点是:排查这类问题,不要只盯应用本身。如果攻击者已经能在宿主机上为所欲为,他完全有可能直接读写正在运行的JVM进程。安全对抗是体系化的,内存马只是其中一个很小的战场。希望这篇文章能把动态注册Interceptor的原理和检测思路讲透,也希望大家在真实环境里遇到类似现象时,知道该从哪里下手。