做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容器交到DispatcherServlet;
- DispatcherServlet根据request信息,调用
getHandler()遍历容器里的所有HandlerMapping,尝试匹配HandlerExecutionChain; - 如果没有一个HandlerMapping能匹配,抛404或者走NoHandlerFound处理;
- 找到HandlerExecutionChain之后,取出适配的HandlerAdapter;
- HandlerAdapter正式调用Controller方法;
- 调用过程中,HandlerExecutionChain里注册的拦截器会在前后触发;
- Controller返回ModelAndView或者直接写response;
- 最后DispatcherServlet统一处理响应。
整个链路里,第2步是Controller型内存马的关键战场,第6步是Interceptor型内存马的关键战场。内存马注入,说白了就是想办法往这两个步骤里面塞东西。
2.2 HandlerMapping如何把URL变成HandlerExecutionChain
HandlerMapping在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 路径A:BeanNameUrlHandlerMapping的“名字即路由”
Spring早期提供过一个非常朴素的处理器映射器:BeanNameUrlHandlerMapping。它的逻辑很直白——如果一个Bean的名字以/开头,那么这个名字对应的URL路径就会被映射到该Bean。
也就是说,如果你能往容器里注册一个名字为/evil的Bean,SpringMVC会自动用这个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属性,是个Map<String, Object>,保存URL与处理对象之间的映射关系。注入手法是通过反射拿到这个字段,然后直接往里塞一个自定义的Controller对象:
// 只是思路示意,实际环境需要额外处理应用上下文获取等操作 Field field = AbstractHandlerMapping.class.getDeclaredField("urlMap"); field.setAccessible(true); Map<String, Object> urlMap = (Map<String, 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); } }这段代码的思路就是,直接在运行时探测当前版本的构造函数签名,比硬编码可靠得多。防御排查的时候也要注意,不同版本的Spring,MappedInterceptor在容器里的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实现类、MappingRegistry |
| Interceptor型 | 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:format=b,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映射表,把缺乏源码对应的组件做成高危告警,这比事后抓包要主动得多。还是那句老话,技术是用来保护系统的,建议所有实验都在自己授权的测试环境里做。