首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
SpringMVC内存马:Controller与Interceptor注入检测
📅 2026/10/10 21:19:27
✍️ 爱科研究院
👁 阅读 3,247
做Java攻防研究内存马是绕不开的一关。无论是红队打点还是蓝队排查SpringMVC框架下的Controller控制器和Interceptor拦截器都是最容易被动手脚的地方。我在实际项目里既做过注入验证也做过清剿复盘这篇就把自己对SpringMVC内存马原理的理解、手搓注入的几种思路以及对应的检测方法整理成笔记希望能帮到正在研究Java安全的朋友也想让Java开发同学理解为什么Spring容器内部会变成攻击者的重点关注对象。1. 内存马为什么绕不开SpringMVC文件落地的死穴与进程内驻留的合法通道1.1 文件马被杀软和主机侧防线吊打的逻辑传统Webshell一旦落地就绕不开文件检测这道关。经验丰富的处置团队会看上传目录的读写时间、比对文件哈希、扫描Web目录的可疑文件。即便Webshell做得再隐蔽只要它写了磁盘权限维持的根基就一直在。这也是为什么在真实对抗中纯粹的JSP一句话木马越来越不好使——主机侧只要对Web目录做实时监控基本看一眼就能定位。内存马的动机就是绕开“落地”这个环节。攻击者想办法让恶意逻辑直接驻留在JVM进程的内存里不产生实体文件。Web服务器该跑的进程还是那个进程磁盘上也没有新增的JSP或者XML主机侧常规的目录扫描和文件完整性检查全部失效。所谓“内存马”本质是打进了正在运行的应用进程里让代码以运行时对象、动态字节码、或者容器组件挂钩的方式存活。1.2 内存马的目标宿主三层容器结构在SpringMVC里理解内存马有一个三层结构必须先建立起来。最外层是Servlet容器Tomcat、Jetty这类中间层是SpringMVC框架的DispatcherServlet最里层才是业务自己定义的业务逻辑。攻击者想在哪个层做手脚决定了他用哪种注入方式。最外层动手Servlet、Filter、Listener类型的动态注册中间层动手Controller、Interceptor、HandlerMapping类型的注入这也是标题里明确提到的两类最里层动手利用字节码增强或者Java Agent机制直接替换应用中的类行为。本篇重点讨论中间层。为什么这层最值得研究因为DispatcherServlet是所有请求的统一入口Controller和Interceptor又是SpringMVC处理链路里最核心的两个角色。攻击者只要把恶意逻辑挂在任何一个环节上业务不管是内部接口还是对外接口流量都会被夹带私货。1.3 Controller与Interceptor的本质区别很多人一上来就混淆Controller型内存马和Interceptor型内存马。它们的核心差异在于执行点位不同。Controller马目标是“某个URL被路由到一个不存在的处理器”或者“某个已有处理器的逻辑被替换”。注入成功之后攻击者访问某个特定路径时会执行自己的恶意Controller方法。Interceptor马目标是“所有请求在进入Controller之前或者之后都要先经过我”。它更像一个全局钩子拦截路径范围可控可以拦截/**也可以只拦某个固定前缀。Controller是“精准打击”Interceptor是“无差别门岗”。实战里Interceptor马更加可怕的一点是它不需要知道系统里有多少个URL只要注册进容器所有进入SpringMVC管道的请求都会先走拦截器这给请求校验、信息窃取、甚至命令执行都提供了非常自由的切入位置。2. 手搓代码前必须吃透的SpringMVC请求分发原理2.1 DispatcherServlet.doDispatch的固定八步手搓内存马之前强烈建议先把SpringMVC分发过程读一遍。只要理解了DispatcherServlet.doDispatch()这个方法后面看各种注入技巧都很顺。SpringMVC的工作流程大致是固定的几步请求进来后先被Servlet容器交到DispatcherServletDispatcherServlet根据request信息调用getHandler()遍历容器里的所有HandlerMapping尝试匹配HandlerExecutionChain如果没有一个HandlerMapping能匹配抛404或者走NoHandlerFound处理找到HandlerExecutionChain之后取出适配的HandlerAdapterHandlerAdapter正式调用Controller方法调用过程中HandlerExecutionChain里注册的拦截器会在前后触发Controller返回ModelAndView或者直接写response最后DispatcherServlet统一处理响应。整个链路里第2步是Controller型内存马的关键战场第6步是Interceptor型内存马的关键战场。内存马注入说白了就是想办法往这两个步骤里面塞东西。2.2 HandlerMapping如何把URL变成HandlerExecutionChainHandlerMapping在SpringMVC里不是一个类而是一堆实现了HandlerMapping接口的Bean。DispatcherServlet会从Spring容器中拿到所有HandlerMapping类型Bean按照Order值排序逐个调用它们的getHandler()方法直到第一个返回非null。以最常见的RequestMappingHandlerMapping为例它持有两个关键的映射来源PathMatcher和UrlPathHelper负责解析请求路径内部的MappingRegistry实际上是一个以RequestMappingInfo为key、以HandlerMethod为value的注册表。HandlerMethod里包含了Controller类对象、方法对象、参数处理逻辑。所以如果要手搓一个Controller马你要做的不是简单“加一个带了Controller注解的类”——你还要让这个类的某个方法被注册到HandlerMapping的MappingRegistry里或者让处理器映射表里多一个URL到方法的条目。2.3 为什么说动态Bean注册只是前提Mapping表更新才是真正关键我在交流群里看到有人写注入代码第一反应是从WebApplicationContext里手动往BeanFactory注册一个恶意Controller类型的Bean。这种做法只能算一半。Spring的ClassPathBeanDefinitionScanner扫描是在启动阶段做的运行时向容器注册Bean不会自动触发RequestMappingHandlerMapping重新扫描和重建立映射。这就相当于把门牌挂好了但是地图上的标识没更新请求打过来还是找不到路。所以Controller型内存马的核心难度就在于如何让运行时新增的Bean与HandlerMapping的映射关系建立起来。这个“为什么难”是理解整篇文章技术选型的起点。3. Controller型内存马三种注册路径与一个最稳的接管方案3.1 路径ABeanNameUrlHandlerMapping的“名字即路由”Spring早期提供过一个非常朴素的处理器映射器BeanNameUrlHandlerMapping。它的逻辑很直白——如果一个Bean的名字以/开头那么这个名字对应的URL路径就会被映射到该Bean。也就是说如果你能往容器里注册一个名字为/evil的BeanSpringMVC会自动用这个Bean来处理/evil请求。这个规则是BeanNameUrlHandlerMapping在初始化时从容器里扫描所有BeanName生成的不需要额外维护MappingRegistry。所以注入代码可以简化成两步获取当前WebApplicationContext调用registerSingleton(/evil, evilHandlerInstance)注册一个名字以斜杠开头的单例Bean。但这个方案有几个现实问题。首先现代SpringBoot默认不是用BeanNameUrlHandlerMapping而是RequestMappingHandlerMapping所以要确认目标环境里它是不是生效。其次这个映射只解决了“谁能处理该URL”的问题处理器方法返回的模型和视图解析都要自己设计。最后BeanNameUrlHandlerMapping的映射逻辑在注册新Bean后不一定自动刷新映射表有的版本需要手动获取该HandlerMapping实例并调用detectHandlerMethods或者依赖刷新机制踩坑概率不小。3.2 路径B反射写死AbstractHandlerMapping.urlMap这是网上流传比较多的一种老思路。早年Spring的AbstractHandlerMapping里有一个urlMap属性是个MapString, Object保存URL与处理对象之间的映射关系。注入手法是通过反射拿到这个字段然后直接往里塞一个自定义的Controller对象// 只是思路示意实际环境需要额外处理应用上下文获取等操作 Field field AbstractHandlerMapping.class.getDeclaredField(urlMap); field.setAccessible(true); MapString, Object urlMap (MapString, Object) field.get(handlerMapping); urlMap.put(/shell, evilControllerInstance);好处是代码短思路直观直接把映射表改了请求进来就能命中。坏处也很明显其中一大块是Spring版本依赖。新版本Spring 5.x中AbstractHandlerMapping内部静态字段已经演化urlMap字段的存在性、类型、语义在不同版本里有差异甚至内部引入了MappingRegistry结构。如果你的注入目标是新版本Spring反射路径可能直接报NoSuchFieldException。所以这条路径适合历史项目或者低版本环境复盘不适合拿到新项目就用。3.3 路径C自研HandlerMapping兜底接管真正在实际验证过程中体验比较稳的是自研一个实现了HandlerMapping接口的类把它注册成容器里的HandlerMapping Bean利用SpringMVC“遍历所有HandlerMapping直到第一个非null结果”的机制做兜底。思路是如果请求在已有的RequestMappingHandlerMapping里匹配不到任何Controller最终执行到了我们自定义的HandlerMapping。这时候我们的getHandler方法可以直接返回一个手工构造的HandlerExecutionChain并且在这个链里安排自定义拦截器和自定义Handler。示意代码如下public class CustomMapping implements HandlerMapping { private Object handler; public CustomMapping(Object handler) { this.handler handler; } Override public HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { // 自定义匹配逻辑例如请求特征匹配 boolean matched request.getRequestURI().contains(/custom); if (!matched) { return null; // 返回null让后续HandlerMapping继续处理 } HandlerExecutionChain chain new HandlerExecutionChain(handler); // 可在链上追加自定义Interceptor return chain; } }注入步骤获取WebApplicationContext其实就是Spring容器构造一个上面的CustomMapping实例内部持有恶意处理器对象调用容器注册方法把这个CustomMapping注册为一个新的Spring Bean后续请求DispatcherServlet在遍历HandlerMapping时就会多出一个候选者。它最稳的地方在于不依赖Spring内部私有字段不做某个版本的反射硬编码只靠SpringMVC的公开接口和容器注册机制跨版本兼容性在几种方案里面算好的。它还有另一个好处可以自己在getHandler里设置复杂的命中条件比如某个请求头值、某种请求方法、某个IP来源范围这样触发可控性很高。当然它也有局限如果目标应用自带了全局的RequestHandlerMapping优先拦截了所有路径自定义HandlerMapping还排在其他映射器后面那拿到流量的机会就少。所以实战中往往还需要动态调整Order优先级。3.4 为什么自研HandlerMapping不容易被常规扫描器发现这个方案对防御方的迷惑性来自两个方面。第一个原因是它没有新增任何Controller方法也没有向MappingRegistry里添加任何新的RequestMappingInfo。很多审计工具在分析内存马时习惯盯着Controller类和RequestMapping注解做提取但自研HandlerMapping产生的处理器并不经过注解扫描所以容易漏。第二个原因是它的恶意逻辑隐藏在getHandler里而不是常见的preHandle或者Controller的某个invoke方法里。只有沿着HandlerMapping的遍历链逐一检查所有实现类才能发现异常。这也反过来提醒防御方排查内存马时不能只看Controller注解和常用的Interceptor接口要系统遍历容器里所有HandlerMapping、HandlerAdapter、HandlerInterceptor等关键接口的实现类。4. Interceptor型内存马一条MappedInterceptor链的构建4.1 为什么MappedInterceptor是比直接实现HandlerInterceptor更短的路很多人写Interceptor马时第一个念头是直接让恶意类实现HandlerInterceptor然后注册进容器。这个思路在SpringMVC早期版本里不一定可靠因为HandlerInterceptor本身并不会被Spring容器自动装配到处理链上——它需要一个桥梁类去做URL匹配和过滤。SpringMVC框架内部有个特殊的实现类叫MappedInterceptor它的作用就是包装一个HandlerInterceptor并附带URL匹配规则。更关键的是SpringMVC在初始化映射的时候会优先从容器中获取所有MappedInterceptor类型的Bean自动把它们纳入适应适配的拦截器集合。所以在手搓Interceptor型内存马时最短路径是写一个实现HandlerInterceptor接口的恶意拦截器类编写或反射构造一个MappedInterceptor实例把URL模式设成/**把这个MappedInterceptor实例注册进WebApplicationContext。只要Spring容器启动了新的完整的初始化流程它会检索到新注册的MappedInterceptor Bean在后续请求到来时这个拦截器就会被加入HandlerExecutionChain。最终效果是仿佛这个拦截器从应用启动时就在那里。4.2 手搓注入的完整步骤获取上下文、构造对象、注册Bean具体的注入逻辑可以拆成这样几步说明。第一步获取WebApplicationContext。常见做法是通过RequestContextHolder拿到当前请求的ServletRequestAttributes然后调用RequestContextUtils.findWebApplicationContext(request)。这一步只能发生在有请求进来的时候也就意味着攻击者往往需要先有一个命令执行或反序列化的入口。第二步构造函数。直接代码里几行实用操作是HandlerInterceptor interceptor initEvilInterceptor(); MappedInterceptor mappedInterceptor; try { // 优先找全参构造老版本 mappedInterceptor new MappedInterceptor(new String[]{/**}, interceptor); } catch (NoSuchMethodError e) { // 版本差异处理需要看当前Spring版本的实际构造签名 }第三步注册Bean。利用DefaultListableBeanFactory.registerSingleton或者obtainApplicationContext().getBeanFactory().registerSingleton()把对象放入容器。注册完不是马上生效还要看框架是否把Bean扫描进了映射器集合里。在新一点的版本中RequestMappingHandlerMapping或者AbstractHandlerMapping初始化时会收集容器中的MappedInterceptor实例。最稳妥的做法是注册完Bean之后获取对应HandlerMapping实例主动触发一次afterPropertiesSet()或者initApplicationContext()逻辑让拦截器集合重新装载。4.3 Spring版本差异5.3前后的加载方式变化这里必须单独强调一下参数签名与版本差异我踩过最深的坑就在这。Spring 5.3之前MappedInterceptor的构造方法签名是MappedInterceptor(String[] includePatterns, HandlerInterceptor interceptor)用的还是字符串数组做包含匹配。而Spring 5.3及之后内部实现逐渐迁移到PathPattern某些构造方法进入了Deprecated状态同时新增了MappedInterceptor(PathPattern[] includePatterns, HandlerInterceptor interceptor)这类签名。所以在手搓代码时不要写死某一个构造函数尽可能用反射遍历构造函数找到最匹配的一个。一个兼容性较好的做法是Constructor?[] constructors MappedInterceptor.class.getConstructors(); for (Constructor? constructor : constructors) { Class?[] types constructor.getParameterTypes(); if (types[0] String[].class types[1] HandlerInterceptor.class) { return constructor.newInstance(new String[]{/**}, interceptor); } }这段代码的思路就是直接在运行时探测当前版本的构造函数签名比硬编码可靠得多。防御排查的时候也要注意不同版本的SpringMappedInterceptor在容器里的Bean定义结构可能不一样不能只按某一个版本的字段结构去定位。4.4 Interceptor马给防御人员的启示从拦截器这个角度反推防御方所有的拦截器代码审计不能只停留在XML配置里的mvc:interceptors配置或者JavaConfig里addInterceptors那一套静态配置。凡是容器里出现MappedInterceptor类型的Bean并且它注册的路径覆盖过广都要引起警惕。尤其是那些拦截器类本身没有在项目工程源码里出现过的类型属于高风险信号。5. 同类思路的扩展Servlet型、Listener型与Agent型内存马怎么归队5.1 Servlet、Filter与Listener容器层面的注册思路如果不依赖Spring容器直接在Servlet容器层面动手也可以注册动态的Servlet、Filter或者Listener。Tomcat的StandardContext里提供了addServletContainerInitializer这样的路径能够动态加入组件。这类马的特点是不经过DispatcherServlet直接在最外层Servlet容器拦截请求。所以如果你在SpringMVC层面排查Interceptor和Controller都查不出异常但请求显示有奇怪的前置处理就要往上翻一层看Servlet容器里的组件列表是不是多了不该多的东西。5.2 Agent型内存马一览Agent型内存马的原理是利用Java Instrumentation机制在目标JVM启动后或者运行时挂载一个agent.jar拿到ClassFileTransformer能力对指定类的字节码做改写。比如改掉某个关键Controller类的方法字节码让它执行恶意逻辑。Agent型马的隐蔽性最高排查难度也最大因为它甚至可以不在Spring容器里留下任何Bean痕迹。它考验的是JVM层面和类加载层面的检测能力比如检查JVM的agent列表、分析类加载来源、对比类字节码与源文件的一致性这些都属于比较深度的取证工作。5.3 分类带来的检测分层策略做一遍分类之后会发现内存马虽然种类多但每种类型都对应一个特定的“生命周期节点”。做好分层防御比单独背某一种检测命令更靠谱。类型注入位置主要检测手段Controller型HandlerMapping / Controller注册表审计HandlerMapping实现类、MappingRegistryInterceptor型MappedInterceptor / 拦截器链检查容器内Interceptor Bean、拦截路径范围Servlet/Filter型Servlet容器组件注册表查看容器组件列表、事件监听器列表Agent型JVM Instrumentation检查agent加载情况、分析类加载链与字节码来源实战中排查一个未知内存马时我会从上到下逐层做先看Spring容器再看Servlet容器最后拉JVM级别的信息避免出现只盯着一个层面把事情想简单的情况。6. 检测与处置从堆栈、Mapping表、类加载器三层还原现场6.1 阿里Arthas与jmap在内存对象中找异常身份排查内存马最有效的方式之一是趁进程还在运行时把内存对象的状态拉出来看。阿里开源的Arthas在这个场景下非常好用sc命令可以列出所有已加载的类jad命令可以反编译指定类源码。如果你的容器里多了一个不在项目源码里的类并且这个类实现了HandlerInterceptor、HandlerMapping等关键接口基本可以锁定嫌疑。jmap -dump:formatb,file/tmp/dump.bin pid可以做堆转储。拿到堆快照后用MAT分析重点是看DefaultListableBeanFactory的singletonObjects里多出了哪些Bean以及MappingRegistry的映射表里有没有可疑的URL。这一步可以把Controller型和Interceptor型马从Bean层面挖出来。6.2 调用链分析一个请求的真实线程栈还原还有一种实用思路是发起一个测试请求然后在请求过程中抓线程栈看它到底有没有进入预期的处理器链路。正常情况下一个Controller请求会经过DispatcherServlet.doDispatch、RequestMappingHandlerMapping.getHandler等固定链路。如果线程栈里出现了某个你没见过的HandlerExecutionChain构建逻辑或者某个陌生的invoke方法那就要顺手把它背后的类对象完整摸出来。排查时我会顺手记录这几个链路锚点方便和正常状态做对比org.springframework.web.servlet.DispatcherServlet.doDispatchorg.springframework.web.servlet.handler.AbstractHandlerMapping.getHandlerorg.springframework.web.servlet.handler.HandlerExecutionChain.applyPreHandleorg.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod被注入拦截器后流程链路会多一层HandlerInterceptor.preHandle的动态调用而且调用的拦截器类可能来自自定义的ClassLoader。观察到这种情况应该及时做热卸载或隔离处置不能只当成普通的响应缓慢分析。6.3 防御侧自检清单根据我在不同项目里复盘的经验整理了一份SpringMVC应用自检清单适合安全团队对线上系统做一次“体检”检查容器里所有HandlerMapping、HandlerAdapter、HandlerInterceptor实现类的类加载器来源审查RequestMapping的映射表确定每一个URL都可以追溯到源码中的Controller方法检查MappedInterceptor类的includePatterns和excludePatterns拦截路径过宽的立即重点排查关注自定义ClassLoader加载出来的类产品里不应该有大量动态字节码生成对于Tomcat容器检查StandardContext中以动态方式注册的Filter、Servlet、Listener。这套清单覆盖了从Spring框架层到Servlet容器层再到JVM层的主要风险点比单纯搜索某个关键词可靠很多。7. 踩坑复盘我实操中遇到的三件揪心事7.1 上下文获取不到的坑动手太早等于白干第一次写注入Demo时代码逻辑是放在Filter#doFilter里触发但Filter执行顺序比较早某些情况下DispatcherServlet还没来得及完成完整的上下文初始化WebApplicationContext取回来是空的。后来调整了触发时机把注册动作放在一个已经处理过路径分发的线程里做才稳定走通。经验是不要假设请求一到上下文就能拿先打印一下servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE)的返回值确定上下文存在再继续。7.2 HandlerMethod与Object的错位为什么有些版本放进去不生效在Controller型马的注入实验中我一开始直接拿Controller实例放进urlMap启动版本较新时发现完全不生效。原因在于旧版本的映射值可以是Object新版本在某些HandlerMapping实现中要求映射值是HandlerMethod对象而HandlerMethod对象的构造又需要Controller类型和方法对象的信息。这不是代码写得不对而是没理解版本的抽象层级。所以建议动手前先反编译一下目标Spring版本里AbstractHandlerMapping的内部字段和RequestMappingHandlerMapping的具体实现确认映射值类型再决定怎么注入。7.3 拦截器签名不一致兼容性代码要先写好拦截器撞上的问题则是构造签名随版本变化。有一次我跟别人的Demo对照发现对方用new MappedInterceptor(/**, interceptor)直接编译通过我这边却报错。后来发现对方是Spring 5.3之前的项目而我的测试环境是Spring 5.3之后的两个构造函数已经被Deprecated处理参数类型不一致。从那之后凡是涉及Spring内部API的代码我都会加一层反射兜底先探构造签名再实例化。这种做法虽然在“手搓代码”的时候显得啰嗦但能省掉大量版本适配的烦恼。最后想说的内存马的攻防本质其实是对Spring容器生命周期和对象注册机制的理解比拼。攻击者能注入不出奇真正难的是在一堆正常运行Bean里定位异常。我在实际处置中最大的体会是不要迷信某一种扫描工具最可靠的方法是把SpringMVC的请求分发链路读熟把容器里每个关键接口实现类过一遍再配合内存dump做对比。反过来研发团队在开发阶段也可以定期检查类加载器和Mapping映射表把缺乏源码对应的组件做成高危告警这比事后抓包要主动得多。还是那句老话技术是用来保护系统的建议所有实验都在自己授权的测试环境里做。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/10 21:14:26
SpringBoot+Vue汽车配件销售管理系统:设计与实现全攻略
2026/10/10 21:14:26
篮球运动员检测数据集YOLOv5训练实战:从数据格式到模型部署
2026/10/10 21:14:26
认证杯C题论文包:基因筛选与BP神经网络MIV分析全流程
2026/10/10 22:04:32
SpringBoot房屋租赁管理系统实战:权限控制、状态机与缓存优化
2026/10/10 22:04:32
【 一次性搞懂Agent、大模型、API、Token、工具调用】
2026/10/10 22:04:32
换掉 Raycast 的人越来越多:72.6MB 常驻内存、零遥测,Tinycast 凭隐私叙事出圈
2026/10/10 22:04:32
电-气-热综合能源系统耦合优化调度Matlab代码实战
2026/10/10 22:04:32
文本驱动图表生成引擎:从DSL设计到自动布局的完整实践
2026/10/10 21:59:32
基于Python-CNN的狗狗表情识别:从数据集到PyQt界面全流程实战
2026/10/10 0:03:38
工业软件标准化路线图:国产替代的落地施工图
2026/10/10 0:03:38
VCMI安卓版实操指南:原生运行英雄无敌3的3步技术落地
2026/10/10 0:03:38
稀疏多通道盲反褶积的MATLAB算法实现与参数调优
2026/10/10 3:42:06
Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化
2026/10/10 3:42:01
多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系
2026/10/10 3:41:58
hindsight:面向LLM应用的事后可观测性工程实践
2026/10/10 3:41:56
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/10 3:41:54
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/9 11:36:17
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)