Java 内存马(六):Spring MVC Controller 内存马

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

预期响应体:

1
REGISTERED

不携带 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 请求层没有异常。

上一篇
下一篇