Servlet 内存马把路径交给 Tomcat,Controller 内存马则把路由交给 Spring MVC。它不需要新增 Servlet,只要向 RequestMappingHandlerMapping 注册一组请求条件和处理方法,请求就能经过现有的 DispatcherServlet 到达新 Handler。
核心关系只有一条:
1 2
| RequestMappingInfo → HandlerMethod 请求匹配条件 实际处理对象与方法
|
Spring 将这组关系保存在运行时 MappingRegistry 中。源码里没有 @RequestMapping,不代表运行中的路由表里没有对应接口。
本文以 Spring Boot 3、Spring MVC 6 为主要语义,同时说明 Spring Boot 2、Spring MVC 5 的边界。实验路由只返回固定标记,不执行系统命令。
仅在本地或授权靶场验证。动态路由会立即影响当前应用的请求分发。
一、Controller 路由如何进入执行链
Spring MVC 应用通常只有一个前端 Servlet:DispatcherServlet。它收到请求后,依次询问各个 HandlerMapping 谁能处理当前请求。
1 2 3 4 5 6 7
| Tomcat FilterChain → DispatcherServlet → RequestMappingHandlerMapping → MappingRegistry 匹配请求条件 → HandlerMethod → RequestMappingHandlerAdapter → 调用 Controller 方法
|
RequestMappingHandlerMapping 负责常见的 @RequestMapping、@GetMapping 和 @PostMapping。应用启动时,它扫描 Controller Bean,把注解解析成 RequestMappingInfo,再与处理方法组成 HandlerMethod。
可以把它理解成一张比 URL 表更精细的路由表:
| 对象 |
保存内容 |
RequestMappingInfo |
Path、HTTP Method、Params、Headers、Consumes、Produces、自定义条件 |
HandlerMethod |
Handler 实例或 Bean 名称、Java 方法、参数与返回值信息 |
MappingRegistry |
映射关系、路径快速索引、名称索引及并发读写状态 |
因此,两个映射即使 Path 相同,触发面也可能完全不同。例如下面两条路由可以同时存在:
1 2
| GET /api/data + Header: X-Mode=normal POST /api/data + Content-Type: application/json
|
Controller 内存马的隐蔽性正来自这些组合条件。只导出 URL 列表,会漏掉 HTTP Method、Header 和参数条件。
运行时注册使用的是 Spring MVC 公开方法:
1
| handlerMapping.registerMapping(mappingInfo, handler, method);
|
它不要求 Handler 必须由组件扫描发现,也不要求方法上存在映射注解。只要注册成功,MappingRegistry 就会持有 Handler,请求即可到达。
二、受控实验注册一条动态路由
项目只需正常引入 spring-boot-starter-web。下面创建一个实验入口,访问 /lab/controller/register 后,运行时新增一条受 Header 条件保护的路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| package lab.web;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.lang.reflect.Method;
@RestController public final class DynamicControllerLab { private final RequestMappingHandlerMapping handlerMapping;
public DynamicControllerLab( @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping) { this.handlerMapping = handlerMapping; }
@PostMapping("/lab/controller/register") public synchronized String register() throws NoSuchMethodException { RequestMappingInfo mappingInfo = buildMapping();
if (handlerMapping.getHandlerMethods().containsKey(mappingInfo)) { return "ALREADY_EXISTS"; }
LabHandler handler = new LabHandler(); Method method = LabHandler.class.getMethod("handle");
handlerMapping.registerMapping(mappingInfo, handler, method); return "REGISTERED"; }
private RequestMappingInfo buildMapping() { return RequestMappingInfo .paths("/__controller_lab") .methods(RequestMethod.GET) .headers("X-Controller-Lab=trace") .produces(MediaType.TEXT_PLAIN_VALUE) .options(handlerMapping.getBuilderConfiguration()) .build(); }
public static final class LabHandler { public ResponseEntity<String> handle() { return ResponseEntity.ok() .header("X-Controller-Lab-Reached", "true") .contentType(MediaType.TEXT_PLAIN) .body("CONTROLLER_LAB_OK"); } } }
|
先完成注册:
1 2
| curl -i -X POST \ http://127.0.0.1:8080/lab/controller/register
|
预期响应体:
不携带 Header 时,请求不满足完整映射条件:
1
| curl -i http://127.0.0.1:8080/__controller_lab
|
通常返回 404。加上固定 Header 后才能命中:
1 2 3
| curl -i \ -H "X-Controller-Lab: trace" \ http://127.0.0.1:8080/__controller_lab
|
预期结果:
1 2 3 4
| HTTP/1.1 200 X-Controller-Lab-Reached: true
CONTROLLER_LAB_OK
|
实验说明三个事实:动态路由不需要方法映射注解;直接传入的 Handler 不必注册成 Spring Bean;Path 只是触发条件之一。
代码没有直接调用 new RequestMappingInfo(...),而是复用 handlerMapping.getBuilderConfiguration()。这样生成的映射会继承当前应用的路径匹配配置,避免运行中的 HandlerMapping 使用 PathPatternParser,新增映射却按另一套 AntPathMatcher 语义构造。
三、从路由表定位异常 Handler
getHandlerMethods() 可以获得当前注册表的公开快照,不需要先反射 MappingRegistry 私有字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| handlerMapping.getHandlerMethods().forEach((mapping, handlerMethod) -> { Class<?> beanType = handlerMethod.getBeanType(); Object bean = handlerMethod.getBean();
System.out.println("mapping=" + mapping); System.out.println("method=" + handlerMethod.getMethod()); System.out.println("beanType=" + beanType.getName()); System.out.println("beanRef=" + bean); System.out.println("directInstance=" + !(bean instanceof String)); System.out.println("loader=" + beanType.getClassLoader());
ProtectionDomain domain = beanType.getProtectionDomain(); System.out.println("source=" + (domain == null ? null : domain.getCodeSource())); });
|
代码需要导入 java.security.ProtectionDomain。输出后不要只搜 /evil、shell 或随机类名,应沿着映射关系核对:
1 2 3 4 5 6
| RequestMappingInfo 包含哪些条件 → HandlerMethod 指向哪个对象和方法 → Handler 是 Bean 名称还是直接实例 → BeanDefinition 或实例由谁创建 → ClassLoader、CodeSource 和字节码是否可信 → 路由能否由当前发布制品解释
|
正常 Controller 的 Handler 通常表现为 Spring Bean 名称,能够回到 BeanFactory 和 BeanDefinition。直接传入对象也是框架支持的合法用法,但如果该对象不在 BeanFactory 中、源码与制品中没有对应路由、运行时却突然可达,就需要重点调查。
版本判断要分开处理:
| 维度 |
影响 |
| Spring Boot 2 / Spring MVC 5 |
Servlet API 主要是 javax.servlet.* |
| Spring Boot 3 / Spring MVC 6 |
Servlet API 切换为 jakarta.servlet.* |
| 路径匹配策略 |
AntPathMatcher 与 PathPatternParser 的规则和内部条件类型不同 |
| Spring WebFlux |
使用响应式 HandlerMapping 与 WebFilter,不属于 MVC 路由表 |
本文实验没有直接引用 Servlet API,因此主体代码可跨 javax 和 jakarta 代际;但实际 Handler 参数、Filter、Interceptor 和应急工具仍必须匹配应用所用版本。
四、注销映射并验证是否真正消失
注销必须使用注册表中对应的完整 RequestMappingInfo:
1
| handlerMapping.unregisterMapping(mappingInfo);
|
只按字符串删除 /__controller_lab 不可靠,因为同一路径可能存在多组 Method、Header 或 Content-Type 条件。受控实验可在上面的 Controller 中加入:
1 2 3 4 5 6 7 8 9 10 11
| @PostMapping("/lab/controller/unregister") public synchronized String unregister() { RequestMappingInfo mappingInfo = buildMapping();
if (!handlerMapping.getHandlerMethods().containsKey(mappingInfo)) { return "NOT_FOUND"; }
handlerMapping.unregisterMapping(mappingInfo); return "UNREGISTERED"; }
|
执行注销:
1 2
| curl -i -X POST \ http://127.0.0.1:8080/lab/controller/unregister
|
然后完成三项验证:
1 2 3
| getHandlerMethods() 中不再存在完整映射 带 X-Controller-Lab: trace 的请求返回 404 堆中不再存在 MappingRegistry 到 LabHandler 的强引用
|
正在处理的请求可能已经取得 HandlerMethod,所以注销只能阻止后续匹配,不能让已进入执行阶段的请求倒退。生产环境还要继续清除最初的代码执行入口、动态类、恶意 Bean、定时任务或 Agent,否则路由可以再次注册。
Controller 路由只是 Spring MVC 的一个入口。HandlerInterceptor、WebRequestInterceptor、函数式路由和 WebFlux 各自有独立的注册与执行链,不能因为 MVC 的 getHandlerMethods() 干净就认定 Spring 请求层没有异常。