Spring MVC拦截器型内存马原理与排查实战

发布时间:2026/10/23 13:17:02
Spring MVC拦截器型内存马原理与排查实战
搞安全的人对“内存马”这个词肯定不陌生。最近几年攻防演练、HVV值守里越来越多的情况是攻击方已经拿到了一次代码执行机会但不再往磁盘上写webshell文件而是直接把恶意逻辑塞进正在运行的JVM内存里不落地、不重启、不触发文件监控等风头过去再撤。Spring框架在国内业务系统里的覆盖率极高自然成了这类玩法的重灾区。这篇文章我就把主角放在Spring MVC的HandlerInterceptor上聊聊拦截器型内存马的原理、动态注册的实际路径以及防守方应该从哪些角度快速排查和处置。不管是红队想理解攻击链路还是蓝队要补检测盲区这篇都值得看完。1. 拦截器内存马到底是个什么东西1.1 从请求在Spring MVC里的执行顺序说起先回忆一下一个正常请求进入Spring MVC后是怎么走的。请求先到达Servlet容器比如Tomcat然后被DispatcherServlet接管。DispatcherServlet会通过HandlerMapping找到一个合适的Controller方法把它包装成一个HandlerExecutionChain执行链。这个执行链上除了Controller方法的调用信息还挂着一串HandlerInterceptor。HandlerInterceptor是Spring MVC提供的拦截器接口常见实现类里三个方法preHandle在Controller方法调用之前执行postHandle在Controller方法执行之后但视图渲染之前执行afterCompletion在整个请求完成之后执行。大多数业务场景里大家用的鉴权、日志、跨域处理都是在这三个方法上做的。拦截器型内存马的核心思路就是在程序运行过程中通过反射等方式把“自己人”的HandlerInterceptor强行塞进某个全局拦截器列表里。只要这一步成功后续每个匹配到该HandlerMapping的请求都会先经过这个恶意拦截器恶意逻辑就有了触发点。为了便于理解可以把这套流程想象成小区门禁正常流程是访客到门口保安按名单核对然后放行进入单元楼。内存马干的事就是悄悄把自己的同伙身份证塞进保安手里的名单之后每次有人进门同伙都能先行确认一遍要不要做点额外的事。1.2 内存马和传统文件马的本质差别传统Webshell的本质是有落地文件不管是一句话木马还是各种加密马最终都要有一个脚本文件或二进制文件在磁盘上。文件一旦落盘就有被静态扫描、文件完整性检测、目录敏感行为监控发现的风险。内存马则完全没有文件实体恶意类是直接加载到JVM内存里的常规查杀工具基于文件系统扫描很难发现自己。内存马也不是凭空来的它的前提是你必须先有代码执行能力。反序列化漏洞、表达式注入、任意文件上传配合解析、模板注入等等任何一个能让你在目标JVM里执行任意代码的入口都可以作为内存马的前置条件。有了这个入口再通过一段反射链把恶意对象挂到框架合适的位置上一个无文件落地的后门就成了。1.3 为什么有人偏偏选Interceptor而不是FilterSpring项目里常见的动态后门还有Filter型内存马就是往Servlet容器的过滤器链里注册一段恶意Filter。两者都能作为内存马载体但很多人更愿意用Interceptor原因有几个。Filter工作在Servlet容器层它是通过ServletContext的addFilter方法注册进去的生命周期由容器管理和Spring容器是两套体系。Filter里想拿到Spring容器里那些Service、Mapper不是不行但要额外做WebApplicationContextUtils.getWebApplicationContext这种上下文获取步骤。Interceptor就不一样它本身就在Spring MVC体系内只要你能拿到Spring上下文很容易直接获取项目里注册的各类Bean,业务联动的成本低很多。另外从触发时机看Filter会在请求进入Servlet容器后立刻执行连Spring容器都还没介入。Interceptor则在HandlerMapping匹配完成之后执行此时Controller方法还没有被调用但Spring的完整上下文已经在手里了对于恶意逻辑来说能拿到的东西明显更多。当然Filter型内存马的覆盖范围更广比如静态资源、404请求它也能拦这个后面的章节再细对比。2. 动态注册Interceptor的技术路线拆解2.1 第一步先拿到Spring容器的“总闸门”要在Spring环境里动态注册拦截器第一步一定是拿到当前Web应用对应的Spring容器。为什么强调“当前”因为一个Java进程里可能同时运行多个Web应用每个Web应用有自己独立的ClassLoader和Spring容器。尤其是在传统Tomcat多应用部署模式下拿错容器等于什么都没做。常见拿法有两种。第一种如果代码执行时当前线程还处于一个HTTP请求上下文中可以通过RequestContextHolder拿到ServletRequestAttributes再从里面的HttpServletRequest get到ServletContext最后用WebApplicationContextUtils.getWebApplicationContext把Spring容器拽出来。import javax.servlet.http.HttpServletRequest; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.WebApplicationContextUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; public static WebApplicationContext getCurrentWebApplicationContext() { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); return WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()); } return null; }这里有一个容易被忽略的细节RequestContextHolder基于ThreadLocal存储只有当前线程真的在处理请求时才能拿到。如果是异步线程、定时任务线程、或者你是在反序列化的某个阶段执行代码没有请求上下文这条路就走不通。这时候就得换个思路比如从已知的Spring工具类、JMX信息、或者静态缓存里找ServletContext引用。实际操作中大多数人是在已有请求的链路里进行的所以第一种方式更常见。有一个比较绕但稳定的备用方案是先从系统属性或者环境变量里找到某些Spring Boot应用暴露的context路径再通过servletContext的全局命名空间去查。但这属于特殊场景经验平时用得少不展开讲。2.2 第二步找到HandlerMapping这个关键对象拿到Spring容器之后下一步不是马上反射而是要想清楚到底要操作哪个对象。Spring MVC里HandlerMapping是一个接口它的职责就是根据当前请求找到对应的Handler以及对应的拦截器链。常用的实现是RequestMappingHandlerMapping负责把RequestMapping标注的方法映射成HandlerMethod。DispatcherServlet初始化时会从Spring容器里获取所有HandlerMapping类型的Bean排序后存起来每次收到请求就挨个问你能不能处理这个请求RequestMappingHandlerMapping继承自AbstractHandlerMapping而AbstractHandlerMapping内部维护着拦截器列表。代码里可以看到两个关键字段一个是interceptors指的是通过XML或Java Config配置的原始拦截器列表另一个是adaptedInterceptors是经过适配后的HandlerInterceptor列表也是实际在getHandler时被遍历使用的对象。我们需要操作的就是这个adaptedInterceptors列表。只要把恶意对象add进去后续每次getHandler构建执行链的时候它会无条件地参与组装。获取handlerMapping的代码很简单import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; RequestMappingHandlerMapping mapping context.getBean(RequestMappingHandlerMapping.class);如果容器里有多个HandlerMapping实例最好按类型或名称精确取。有些项目用了Spring Cloud Gateway、自定义HandlerMapping名字可能不同要用getBeansOfType先看一眼。2.3 第三步反射往adaptedInterceptors里加料拿到RequestMappingHandlerMapping实例后按理说直接调用addInterceptor方法最省事但这个方法不是公开的真正的初始化逻辑是在AbstractHandlerMapping.initApplicationContext里完成的。所以动态注入只能走反射。核心反射路径如下Class? abstractClass Class.forName(org.springframework.web.servlet.handler.AbstractHandlerMapping); Field field abstractClass.getDeclaredField(adaptedInterceptors); field.setAccessible(true); ListHandlerInterceptor list (ListHandlerInterceptor) field.get(mapping);在Spring 5和大多数Spring Boot 2.x版本中adaptedInterceptors字段的类型是List 实际对象是ArrayList可以直接add。在新一点的Spring 6版本里字段名和类型可能有调整但范围没变仍然在这个类身上。包装一个拦截器对象塞进去import org.springframework.web.servlet.HandlerInterceptor; class DemoInterceptor implements HandlerInterceptor { Override public boolean preHandle(jakarta.servlet.http.HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, Object handler) throws Exception { // 这里仅作演示没有任何恶意动作 System.out.println([demo] interceptor triggered); return true; } } // 反射获取list后 list.add(new DemoInterceptor());注意如果直接add一个实现了HandlerInterceptor的普通对象它会成为全局拦截器作用于当前HandlerMapping能匹配到的所有请求。如果想要只对特定路径生效应该使用MappedInterceptor它会匹配includePatterns和excludePatternsMappedInterceptor mappedInterceptor new MappedInterceptor(new String[]{/admin/**}, new String[]{/admin/login}, interceptor); list.add(mappedInterceptor);不过大多数场景下全局拦截器已经够用了。攻击者只要找一个真实存在且会进入Controller的URL就能触发。反射设置字段时要注意Java模块系统某些高版本JDK在强封装下需要加--add-opens。实际打点环境大多是JDK8或JDK11这块问题不大。2.4 另一种思路动态注册Bean再自动挂载除了直接操作adaptedInterceptors还有一类实现方式是把恶意对象通过BeanFactory动态注册成Spring Bean然后让Spring在容器刷新时把它自动纳入拦截器列表。常见的做法是往DefaultListableBeanFactory里registerBeanDefinition或者直接调用GenericApplicationContext的registerBean方法。这种写法的好处是隐蔽性更强因为对象会出现在Spring容器管理范围里后续可以通过getBean获取而不只是一段游离在内存里的匿名对象。坏处是步骤更多而且Bean生命周期回调容易出问题触发条件也更苛刻。实际攻防场景里直接反射操作adaptedInterceptors是更短平快的方案大多数用于应急分析和安全研究的技术文章也都把重点放在这一条路线上。3. 实战视角从漏洞入口到拦截器生效的完整链路3.1 前置条件得先有“一次代码执行”说一句容易让人误解的话内存马不是漏洞它只是漏洞利用的一种载体。没有入口漏洞再精妙的注册逻辑也执行不到。所以完整的攻击链路一定是这样的外部入口漏洞 → 在目标JVM里执行了一段任意代码 → 通过反射获取Spring上下文 → 获取HandlerMapping → 向adaptedInterceptors中注册恶意拦截器 → 后续请求触发拦截器执行恶意逻辑。所谓的入口常见的是反序列化RCE、表达式注入、模版注入、不安全的文件上传配合解析、以及某些管理端口的未授权命令执行。不管入口长什么样最终都要落到能执行Java字节码或能调用反射API这个层面。我在一些授权演练里看到过一种比较典型的场景某个内部系统存在一个基于JDBC反序列化的接口攻击方把payload发进去之后会先弹一个经过混淆的“加载器”这个加载器负责在当前JVM里完成内存马注册。因为整个过程没有任何文件落地业务方在攻击发生后的几天内都没发现异常直到红队报告出来后蓝队才通过专门的内存马检测脚本找到了那段挂在RequestMappingHandlerMapping里的陌生拦截器。3.2 注册关键参数怎么选路径、类和加载器写注册代码的时候有三个参数需要仔细考虑。第一个是目标路径。如果注册的是MappedInterceptorincludePatterns写成/**看起来最稳但这里有个误区拦截器只在能匹配到Handler时才执行如果一个请求根本找不到Controller或静态资源处理器HandlerMapping返回的可能是null拦截器链压根不会构建。很多人在自己机器上测内存马随便访问一个能正常返回数据的接口一切正常但在目标环境里选了一个不存在的路径结果触发不了还以为注册失败。所以更靠谱的方法是先观察目标系统里有哪些公开可达的Controller路径然后选择其中一个真实存在的请求来触发。Spring Boot自带的/error错误页路径通常也是可用的因为BasicErrorController处理了这个路径有对应的Handler。第二个是类加载器。内存马所在的恶意类用什么ClassLoader加载会直接决定它能访问哪些类。如果是在入口漏洞的同一个线程里直接new对象一般用的就是当前线程的ContextClassLoader它能加载到Spring相关类基本没问题。但如果是通过反序列化从远程加载了一个Jar再用URLClassLoader加载恶意类这时候恶意类的父加载器不一定能看到Spring的API就会出现ClassCastException。实战中的常见姿势是先把恶意类字节码用当前Web应用的ClassLoader去defineClass保证类空间和Spring容器一致。第三个是触发标记。拦截器内存马和普通登录逻辑最大的区别是它需要通过某种方式知道自己是否被“激活”。常见做法是检查特定请求头、特定Cookie、特定URL参数命中了才执行恶意逻辑否则原样放行。这种做法让它在访问日志里看起来就是一个普通请求串。3.3 触发验证要访问哪个URL才有效注册完成之后验证是否生效也不能随便找一个URL。前面提到只有HandlerMapping能够成功getHandler的路径才会走拦截器链。如果目标请求路径不存在DispatcherServlet会走到NoHandlerFound处理逻辑或者直接404第一层getHandler就返回null拦截器根本没机会跑。这其实是很多初次接触拦截器内存马的人最容易踩的坑。Filter型内存马没有这个问题因为Filter是Servlet容器层面的不管Handler有没有找到都会经过它。Interceptor是Spring MVC层面的离Controller更近但也因此更“挑剔”。验证时最稳妥的方式是注册一个平台自己的心跳请求路径比如某些管理后台的登录接口、首页、静态资源转发接口访问一次然后在响应或日志里看到预埋标记。为了不干扰业务好的验证姿势是让恶意拦截器在匹配到特定触发条件时才改变响应其他情况完全透明。3.4 为什么类加载器是最大的坑接着类加载器的话题往下说。Tomcat为了实现多应用隔离给每个Web应用创建了独立的WebAppClassLoader双亲委派顺序是先找父加载器再自己加载。Spring的类也是通过Web应用的这个加载器加载的。如果你在当前请求线程里直接写Class.forName(org.springframework.web.servlet.HandlerInterceptor)通常能加载到因为当前线程上下文加载器就是Web应用加载器。但如果你的恶意逻辑是放在一个独立线程里执行的而那个线程的ContextClassLoader是系统类加载器再去加载Spring的类很可能失败或拿到不同命名空间里的类。这时候就要手动把线程上下文加载器切换成目标Web应用的加载器操作完再恢复。还有一个隐蔽问题有些序列化框架在反序列化时会使用调用方的ClassLoader而不是被序列化对象的ClassLoader。也就是说即使你在生成payload时用的是应用服务器类加载器反序列化时还可能因为类加载器上下文不同导致加载失败。这也是为什么很多内存马实现会优先用字节码defineClass而不是依赖外部Jar加载。4. 拦截器内存马和Filter内存马到底选谁4.1 两类内存马的作用范围差异Filter型内存马作用于Servlet容器层面。只要请求进入了Servlet容器Filter就会在请求到达DispatcherServlet之前被调用。这意味着静态资源、错误页、以及那些根本没有对应Handler的404请求Filter都能看到。Interceptor型内存马只作用于Spring MVC内部前面已经反复强调必须有Handler匹配成功才会执行。从“覆盖面”来看Filter明显更广从“业务可达性”来看Interceptor能直接接触Spring Bean更灵活。两者对比可以看下面这个表。对比项Filter内存马Interceptor内存马作用层级Servlet容器层Spring MVC层触发条件任意请求进入容器即触发需HandlerMapping匹配到Handler是否覆盖404覆盖不覆盖获取Spring Bean需要额外获取上下文天然在Spring环境中注册方式ServletContext.addFilter反射操作HandlerMapping拦截器列表典型排查位置容器Filter链Spring容器内HandlerMapping4.2 获取业务对象能力的差异真实攻防中拿到了内存马之后还想做深入利用比如读配置、调内部Service、查数据库这些都需要拿到Spring容器里的Bean。Filter型内存马虽然理论上可以通过WebApplicationContextUtils.getWebApplicationContext拿到容器但它本身在Servlet容器层要绕一步。Interceptor不同它的实例虽然在内存里是被临时塞进去的但只要代码里持有ApplicationContext引用Controller能干的活它基本都能干。简而言之如果目标是长期维护权限、方便横向移动Interceptor型内存马更有吸引力。如果目标是高覆盖率、无差别流量劫持Filter型更合适。4.3 排查难度和存活性差异对防守方来说这两类内存马的排查难度不一样。Filter型内存马排查时可以去看ServletContext里的FilterRegistration列表Java里遍历注册信息相对直观。Interceptor型内存马没有现成的注册表只能依赖反射拿HandlerMapping里的adaptedInterceptors列表然后再判断列表里每一项的类名、类加载器、字节码来源是否可疑排查门槛略高。存活方面两者的共同点是重启即失。内存马最大的弱点就是不能持久化一旦JVM重启所有动态注册的列表全部清空。所以很多攻击者会想办法同时保留一份落地脚本等目标重启后再通过其他入口重新注册但这又增加了暴露面。防守方只要坚持在每次重启后进行一轮内存马巡检就能很大程度上压缩这类马的存活空间。5. 防守方的快速排查与处置5.1 三分钟自查遍历HandlerMapping里的拦截器面对一个疑似被植入内存马的Spring应用最快的方式就是写一个小接口或直接用Java进程外脚本遍历所有HandlerMapping里的适配拦截器列表看有没有不该出现的东西。下面是一个简单的检测逻辑可以放在一个临时Controller里也可以放到JMX调用方里。import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.handler.AbstractHandlerMapping; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public static MapString, Object inspectInterceptors(WebApplicationContext context) throws Exception { MapString, Object result new HashMap(); String[] names context.getBeanNamesForType(HandlerMapping.class); ListMapString, String interceptorsInfo new ArrayList(); for (String name : names) { Object handlerMapping context.getBean(name); if (!(handlerMapping instanceof AbstractHandlerMapping)) { continue; } Field interceptorsField AbstractHandlerMapping.class.getDeclaredField(adaptedInterceptors); interceptorsField.setAccessible(true); List? interceptors (List?) interceptorsField.get(handlerMapping); for (Object interceptor : interceptors) { MapString, String info new HashMap(); info.put(handlerMapping, name); info.put(interceptorClass, interceptor.getClass().getName()); info.put(classLoader, interceptor.getClass().getClassLoader().toString()); interceptorsInfo.add(info); } } result.put(interceptors, interceptorsInfo); return result; }跑了这个脚本之后重点看两个东西一是类名是否像随机生成的例如UUID片段、Base64变体二是ClassLoader来源是否正常。大部分正常业务拦截器都是项目自己写的类加载器是应用类加载器。如果看到类是URLClassLoader从临时目录或远程地址加载的几乎可以判定有问题。5.2 可疑拦截器的几个特征在真实排查经验里内存马拦截器有几个比较明显的特征。第一类名非常没有“业务感”。正常拦截器会叫AuthInterceptor、LogInterceptor、XssInterceptor内存马往往叫random类名或者干脆是内部类、动态代理类反编译一看代码逻辑极其简短。第二preHandle方法逻辑非常集中。正常拦截器preHandle里会调用各种Service、写日志、处理业务而恶意拦截器通常只有短短几行判断触发条件、执行恶意动作、返回true或false。它的代码量少得和它的权限完全不匹配。第三它可能不在任何配置文件中出现。你翻遍spring-mvc.xml、WebMvcConfigurer、ComponentScan基础包都找不到这个类的声明可它就是出现在运行时的adaptedInterceptors里。这说明它是被动态塞进去的而不是通过正常配置注册的。5.3 排查之后怎么处理确认存在恶意拦截器之后最简单的处置方式就是重启应用让它随JVM一起消失。但这只是治标因为入口漏洞还在重启之后攻击方很可能再次打进来。更稳妥的做法是先通过流量日志和应急响应找到漏洞入口把入口修复之后再做一次重启。否则就是给攻击者演示了一遍“杀马不杀源”。另外可以借助RASP产品在底层拦截对adaptedInterceptors的add操作。很多RASP方案会Hook关键类比如AbstractHandlerMapping.initApplicationContext、HandlerMethod等对非白名单的拦截器注入行为直接阻断。自己写Agent也可以但成本和维护难度不低适合有专门安全团队的公司。5.4 加固Spring应用的落地清单在日常防护上我更推荐提前做这些动作。升级Spring框架和Spring Boot版本封堵已知反序列化、表达式注入入口。对上传功能做严格的白名单校验避免任意文件落地。关闭或收敛管理端点比如Actuator、JMX、RMI等禁止生产环境暴露。对反序列化使用白名单过滤避免不可信数据直接进入ObjectInputStream。建立内存马巡检脚本在每次发布重启后自动扫描HandlerMapping和Filter链。在边界WAF层面对高危险路径和特征请求做告警虽然不能完全防内存马但能提供线索。应用进程尽量使用独立低权限用户运行最小化被入侵后的影响面。确保日志完整尤其要记录所有请求的URL、来源IP、User-Agent方便事后回溯。这些动作里前四条本质上是在堵“入口”。内存马再厉害也需要一个能执行代码的点。入口堵住了内存马就是无源之水。6. 常见问题与排查心得6.1 这里有一份问题速查表现象可能原因处理方法getBean(RequestMappingHandlerMapping.class)报错容器中没有这个类型的Bean或项目使用非标准HandlerMapping改用getBeansOfType遍历所有HandlerMapping反射获取adaptedInterceptors字段失败Spring版本或类结构变化遍历getDeclaredFields找到List类型字段后再操作list.add之后拦截器不生效访问路径没有匹配到Handler换成真实存在的Controller路径或检查MappedInterceptor的path配置调用时出现ClassCastException类加载器命名空间不一致确保恶意类由当前Web应用ClassLoader加载必要时手动defineClass请求到达但preHandle没执行被更早的拦截器拦截或Handler匹配失败查看执行链构建情况用检测类打印拦截器数量restart后重启生效内存马特性本来就不持久配合持久化入口修复重启清理6.2 我自己的几个排查心得内存马这个领域技术花样虽然多底层的道理其实很朴素攻击者需要找到一个能在运行时往框架对象图里塞东西的办法防守者就需要定期检查运行时对象图里有没有多出来的东西。我见过不少防守团队买了昂贵的安全产品却从来没想过自己写一个几十行的检测脚本去遍历HandlerMapping的适配拦截器。其实这类代码成本很低效果却非常直接。另一点比较重要的体会是内存马排查一定要结合入口视角。有些时候你找到一个陌生的拦截器急着清掉、重启结果过了两天又出现一模一样的加料原因就是入口漏洞没补。真正有效的流程是发现可疑对象 → 定位注入来源 → 修复入口 → 重启清理 → 持续观察。跳过任何一环后面的工作都会变成打地鼠。最后再分享一个小技巧在巡检脚本里除了看类名和ClassLoader还可以对每一段字节码的SHA-256做基线管理。每次发布后自动记录正常拦截器类的哈希巡检时一旦发现哈希不在基线库里的类直接告警。这样连动态代理类、匿名内部类这类名字本来就很乱的异常对象也能被识别出来。这种基于基线的思路比单纯靠人工看类名要可靠得多。