Servlet 通过 URL 触发,Filter 通过请求链触发,Listener 则等待事件发生。请求刚创建、Session 刚建立、应用刚启动,甚至某个属性被修改,都可能触发不同 Listener。
它没有统一的“后门路径”,也不一定出现在 Servlet 和 Filter 注册表中。判断 Listener 是否能接触请求,关键不是类名,而是它实现了哪个监听接口,以及该接口会收到什么事件对象。
本文以 Tomcat 10.1 和 jakarta.servlet.* 为实验基线。示例只在 Request 中写入固定标记,不执行系统命令。
仅在本地或授权靶场验证。Tomcat 内部 Listener 数组不属于稳定的 Servlet API。
一、Listener 到底监听什么
Listener 可以理解为容器中的“事件订阅者”:它不负责匹配 URL,只在订阅的事件发生时由 Tomcat 主动回调。
| 接口 |
触发时机 |
回调中可获得的主要对象 |
ServletContextListener |
Web 应用启动、销毁 |
ServletContext |
ServletRequestListener |
请求初始化、销毁 |
ServletRequest、ServletContext |
HttpSessionListener |
Session 创建、销毁 |
HttpSession |
ServletRequestAttributeListener |
Request 属性增删改 |
属性名、值和 Request |
HttpSessionAttributeListener |
Session 属性增删改 |
属性名、值和 Session |
ServletContextAttributeListener |
Context 属性增删改 |
属性名、值和 Context |

以 ServletRequestListener 为例,请求处理顺序可以简化为:
1 2 3 4 5 6
| Tomcat 创建 Request → requestInitialized() → FilterChain → Servlet / Controller → requestDestroyed() → 请求结束
|
这意味着它比 Filter 更早接触请求,但事件参数中只有 ServletRequest,没有 HttpServletResponse。原生 Listener 不能凭空调用 getResponse();若要影响响应,必须借助请求属性、包装对象、线程上下文或其他容器内部关系。分析代码时,遇到不存在的 API 应先判断示例是否真实可运行。
Tomcat 内部又将 Listener 分成两组:
1 2
| applicationLifecycleListeners 应用和 Session 生命周期相关 applicationEventListeners Request、属性等事件相关
|
实现接口决定实例进入哪一组。只枚举其中一个数组,会直接漏掉另一类 Listener。
二、受控靶场完成运行时注册
下面的 JSP 注册一个 ServletRequestListener。当请求携带 X-Listener-Lab: trace 时,Listener 在 requestInitialized() 中写入 Request Attribute;随后 JSP 读取该值,从而证明回调发生在页面执行之前。
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
| <%@ page import="java.lang.reflect.Field" %> <%@ page import="jakarta.servlet.ServletContext" %> <%@ page import="jakarta.servlet.ServletRequestEvent" %> <%@ page import="jakarta.servlet.ServletRequestListener" %> <%@ page import="jakarta.servlet.http.HttpServletRequest" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page contentType="text/plain;charset=UTF-8" %>
<%! private static final String MARKER_KEY = "listener.lab.marker";
public static final class LabRequestListener implements ServletRequestListener {
@Override public void requestInitialized(ServletRequestEvent event) { HttpServletRequest request = (HttpServletRequest) event.getServletRequest();
if ("trace".equals(request.getHeader("X-Listener-Lab"))) { request.setAttribute(MARKER_KEY, "REQUEST_INITIALIZED"); } }
@Override public void requestDestroyed(ServletRequestEvent event) { } }
private Object readField(Object target, String name) throws Exception { Class<?> type = target.getClass(); while (type != null) { try { Field field = type.getDeclaredField(name); field.setAccessible(true); return field.get(target); } catch (NoSuchFieldException ignored) { type = type.getSuperclass(); } } throw new NoSuchFieldException(name); } %>
<% ServletContext facade = request.getServletContext(); ApplicationContext applicationContext = (ApplicationContext) readField(facade, "context"); StandardContext standardContext = (StandardContext) readField(applicationContext, "context");
boolean exists = false; for (Object listener : standardContext.getApplicationEventListeners()) { if (listener instanceof LabRequestListener) { exists = true; break; } }
if (!exists) { standardContext.addApplicationEventListener( new LabRequestListener()); response.getWriter().println("REGISTERED"); } else { response.getWriter().println("ALREADY_EXISTS"); }
response.getWriter().println( "marker=" + request.getAttribute(MARKER_KEY)); %>
|
第一次访问只完成注册,此次请求创建时 Listener 还不存在:
1
| curl -i http://127.0.0.1:8080/app/listener-lab.jsp
|
预期输出:
第二次携带触发请求头:
1 2
| curl -i -H "X-Listener-Lab: trace" \ http://127.0.0.1:8080/app/listener-lab.jsp
|
预期输出:
1 2
| ALREADY_EXISTS marker=REQUEST_INITIALIZED
|
第二次请求进入 JSP 之前,Listener 已经设置好属性。这里没有新增 URL 映射,也没有加入 FilterChain,触发点完全来自 Tomcat 的请求事件分发。
三、枚举时必须识别接口和来源
Listener 没有 FilterMap,排查时应直接查看两组实例,并输出它们实现的全部监听接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| private static void printListener(String group, Object listener) { Class<?> type = listener.getClass();
System.out.println("group=" + group); System.out.println(" class=" + type.getName()); System.out.println(" loader=" + type.getClassLoader());
for (Class<?> iface : type.getInterfaces()) { System.out.println(" interface=" + iface.getName()); }
ProtectionDomain domain = type.getProtectionDomain(); System.out.println(" source=" + (domain == null ? null : domain.getCodeSource())); }
for (Object listener : standardContext.getApplicationEventListeners()) { printListener("event", listener); }
for (Object listener : standardContext.getApplicationLifecycleListeners()) { printListener("lifecycle", listener); }
|
排查顺序可以固定为四步:
1 2 3 4
| 实例属于哪一组 → 实现了哪些 Listener 接口 → 什么事件会触发它 → 类、ClassLoader 和 CodeSource 能否由发布制品解释
|
还要与以下正常来源比较:
web.xml 中的 <listener>;
@WebListener 注解扫描结果;
ServletContainerInitializer 和框架启动代码;
- Tomcat 或应用服务器自身装配的监听器;
- 同版本、同制品的可信节点运行时清单。
类名中包含 Listener 没有判断价值。真正重要的是实例是否未经授权进入数组、它订阅的事件是否与业务相符,以及实际加载的字节码是否可信。
四、精确移除与事件验证
Tomcat 没有为动态事件 Listener 提供通用的单实例删除接口。受控环境中可以读取当前数组,按对象身份筛除目标,再整体写回:
1 2 3 4 5 6 7 8 9 10 11
| Object[] current = standardContext.getApplicationEventListeners(); List<Object> retained = new ArrayList<>();
for (Object listener : current) { if (!(listener instanceof LabRequestListener)) { retained.add(listener); } }
standardContext.setApplicationEventListeners( retained.toArray(new Object[0]));
|
代码需要导入 java.util.ArrayList 和 java.util.List。若目标位于生命周期组,则应核对当前 Tomcat 版本对应的生命周期 Listener 维护方式,不能误改事件组。
卸载后再次发送带 X-Listener-Lab: trace 的请求,marker 应恢复为 null;重新枚举时目标实例也应消失。验证不能只访问普通页面,还要按其实现接口触发对应事件,例如创建 Session、修改属性或执行异步请求。
直接替换数组存在并发边界:正在分发的请求可能已经取得旧 Listener 引用,requestInitialized() 与 requestDestroyed() 也可能跨越修改时点。生产环境应先摘除节点、保存两组 Listener 清单和堆证据,再修复植入入口并从可信制品重建,而不是在流量中直接改写数组。